From 8d8b02a3ec4666399a6fcea049f53c3aee8ab700 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 10 Apr 2026 22:34:57 +0500 Subject: [PATCH 01/32] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20reply=20?= =?UTF-8?q?=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B0=D1=85=20=D1=81=20?= =?UTF-8?q?Desktop=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B0=D0=BB=D1=81=D1=8F=20(hex=20key=20fallback=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20reply=20blob).=20=D0=9E=D0=BF=D1=82=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20circular=20reveal=20(pre?= =?UTF-8?q?warm=20bitmap).=20=D0=9B=D0=BE=D0=B3=D0=B8=20reply=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B2=20rosettade?= =?UTF-8?q?v1.=20=D0=A1=D0=B5=D1=80=D1=8B=D0=B5=20=D0=BC=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=B0=D1=82=D1=8E=D1=80=D1=8B=20=D0=B2=20=D0=BC=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=20(BlurHash).=20=D0=90=D0=BD=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BE=D0=BD=D0=B1=D0=BE=D1=80=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B0=20=D0=BD=D0=B0=20Animatable=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20while-loop.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/data/MessageRepository.kt | 28 ++++- .../messenger/ui/chats/ChatViewModel.kt | 81 +++++++++++--- .../messenger/ui/chats/ChatsListScreen.kt | 21 +++- .../chats/components/ChatDetailComponents.kt | 7 +- .../ui/onboarding/OnboardingScreen.kt | 104 +++++------------- .../rosetta/messenger/ui/qr/MyQrCodeScreen.kt | 13 ++- .../messenger/ui/settings/ThemeScreen.kt | 10 +- 7 files changed, 161 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index bbb555a..8c5fbad 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -1853,7 +1853,7 @@ class MessageRepository private constructor(private val context: Context) { // 1. Расшифровываем blob с ChaCha ключом сообщения val decryptedBlob = if (groupKey != null) { - CryptoManager.decryptWithPassword(attachment.blob, groupKey) + decryptWithGroupKeyCompat(attachment.blob, groupKey) } else { plainKeyAndNonce?.let { MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) @@ -1910,7 +1910,7 @@ class MessageRepository private constructor(private val context: Context) { // 1. Расшифровываем blob с ChaCha ключом сообщения val decryptedBlob = if (groupKey != null) { - CryptoManager.decryptWithPassword(attachment.blob, groupKey) + decryptWithGroupKeyCompat(attachment.blob, groupKey) } else { plainKeyAndNonce?.let { MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) @@ -1974,7 +1974,7 @@ class MessageRepository private constructor(private val context: Context) { // 1. Расшифровываем с ChaCha ключом сообщения val decryptedBlob = if (groupKey != null) { - CryptoManager.decryptWithPassword(attachment.blob, groupKey) + decryptWithGroupKeyCompat(attachment.blob, groupKey) } else { plainKeyAndNonce?.let { MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) @@ -2039,4 +2039,26 @@ class MessageRepository private constructor(private val context: Context) { } return jsonArray.toString() } + + /** + * Desktop parity for group attachment blobs: + * old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes). + */ + private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? { + if (encryptedBlob.isBlank() || groupKey.isBlank()) return null + + val rawAttempt = runCatching { + CryptoManager.decryptWithPassword(encryptedBlob, groupKey) + }.getOrNull() + if (rawAttempt != null) return rawAttempt + + val hexKey = + groupKey.toByteArray(Charsets.ISO_8859_1) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + if (hexKey == groupKey) return null + + return runCatching { + CryptoManager.decryptWithPassword(encryptedBlob, hexKey) + }.getOrNull() + } } 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 9fd0e16..d557597 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 @@ -1872,6 +1872,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val forwardedMessages: List = emptyList() ) + private fun replyLog(msg: String) { + try { + val ctx = getApplication() + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) + val dir = java.io.File(ctx.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + java.io.File(dir, "rosettadev1.txt").appendText("$ts [Reply] $msg\n") + } catch (_: Exception) {} + } + private suspend fun parseReplyFromAttachments( attachmentsJson: String, isFromMe: Boolean, @@ -1887,26 +1897,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } return try { - val attachments = JSONArray(attachmentsJson) + val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return null for (i in 0 until attachments.length()) { val attachment = attachments.getJSONObject(i) - val type = attachment.optInt("type", 0) + val type = parseAttachmentType(attachment) // MESSAGES = 1 (цитата) - if (type == 1) { + if (type == AttachmentType.MESSAGES) { + replyLog("=== PARSE REPLY: isFromMe=$isFromMe, hasGroup=${groupPassword != null}, chachaKey=${chachaKey.take(12)}, hasPlainKey=${plainKeyAndNonce != null} ===") // Данные могут быть в blob или preview var dataJson = attachment.optString("blob", "") if (dataJson.isEmpty()) { dataJson = attachment.optString("preview", "") + replyLog(" blob empty, using preview") } if (dataJson.isEmpty()) { + replyLog(" BOTH empty → skip") continue } + replyLog(" dataJson.len=${dataJson.length}, colons=${dataJson.count { it == ':' }}, starts='${dataJson.take(20)}'") + // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат // "iv:ciphertext" val colonCount = dataJson.count { it == ':' } @@ -1914,21 +1929,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (dataJson.contains(":") && dataJson.split(":").size == 2) { val privateKey = myPrivateKey var decryptionSuccess = false + replyLog(" encrypted format detected (iv:cipher), trying decrypt methods...") // 🔥 Способ 0: Группа — blob шифруется ключом группы - if (groupPassword != null) { + if (groupPassword != null && !decryptionSuccess) { + replyLog(" [0] group raw key (len=${groupPassword.length})") try { val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword) if (decrypted != null) { dataJson = decrypted decryptionSuccess = true + replyLog(" [0] OK raw key") + } else { + replyLog(" [0] raw key → null") } - } catch (_: Exception) {} + } catch (e: Exception) { replyLog(" [0] raw key EXCEPTION: ${e.message}") } + // Fallback: Desktop v1.2.1+ шифрует hex-версией ключа + if (!decryptionSuccess) { + try { + val hexKey = groupPassword.toByteArray(Charsets.ISO_8859_1) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + replyLog(" [0] trying hex key (len=${hexKey.length})") + val decrypted = CryptoManager.decryptWithPassword(dataJson, hexKey) + if (decrypted != null) { + dataJson = decrypted + decryptionSuccess = true + replyLog(" [0] OK hex key") + } else { + replyLog(" [0] hex key → null") + } + } catch (e: Exception) { replyLog(" [0] hex key EXCEPTION: ${e.message}") } + } } - // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих - // сообщений) - if (privateKey != null) { + // 🔥 Способ 1: Пробуем расшифровать с приватным ключом + if (privateKey != null && !decryptionSuccess) { + replyLog(" [1] private key") try { val decrypted = CryptoManager.decryptWithPassword(dataJson, privateKey) @@ -1998,26 +2034,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) {} } + replyLog(" FINAL: decryptionSuccess=$decryptionSuccess") if (!decryptionSuccess) { + replyLog(" ALL METHODS FAILED → skip") continue } - } else {} + } else { + replyLog(" NOT encrypted (no iv:cipher format), treating as plain JSON") + } val messagesArray = try { JSONArray(dataJson) } catch (e: Exception) { + replyLog(" JSON parse FAILED: ${e.message?.take(50)}") + replyLog(" dataJson preview: '${dataJson.take(80)}'") continue } + replyLog(" JSON OK: ${messagesArray.length()} messages") if (messagesArray.length() > 0) { val account = myPublicKey ?: return null val dialogKey = getDialogKey(account, opponentKey ?: "") - // Check if this is a forwarded set or a regular reply - // Desktop doesn't set "forwarded" flag, but sends multiple messages in the array val firstMsg = messagesArray.getJSONObject(0) val isForwardedSet = firstMsg.optBoolean("forwarded", false) || messagesArray.length() > 1 + replyLog(" isForwardedSet=$isForwardedSet, firstMsg keys=${firstMsg.keys().asSequence().toList()}") if (isForwardedSet) { // 🔥 Parse ALL forwarded messages (desktop parity) @@ -2120,6 +2162,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { chachaKeyPlainHex = fwdChachaKeyPlain )) } + replyLog(" RESULT: forwarded ${forwardedList.size} messages") return ParsedReplyResult( replyData = forwardedList.firstOrNull(), forwardedMessages = forwardedList @@ -2135,13 +2178,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val senderNameFromJson = replyMessage.optString("senderName", "") val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "") - // 🔥 Detect forward: explicit flag OR publicKey belongs to a third party - // Desktop doesn't send "forwarded" flag, but if publicKey differs from - // both myPublicKey and opponentKey — it's a forwarded message from someone else - val isFromThirdParty = replyPublicKey.isNotEmpty() && + // 🔥 Detect forward: + // - explicit "forwarded" flag always wins + // - third-party heuristic applies ONLY for direct dialogs + // (in groups reply author is naturally "third-party", and that must remain a reply) + val isGroupContext = isGroupDialogKey(opponentKey ?: "") || isGroupDialogKey(dialogKey) + val isFromThirdPartyDirect = !isGroupContext && + replyPublicKey.isNotEmpty() && replyPublicKey != myPublicKey && replyPublicKey != opponentKey - val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty + val isForwarded = + replyMessage.optBoolean("forwarded", false) || isFromThirdPartyDirect // 📸 Парсим attachments из JSON reply (как в Desktop) val replyAttachmentsFromJson = mutableListOf() @@ -2308,11 +2355,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 If this is a forwarded message (from third party), return as forwardedMessages list // so it renders with "Forwarded from" header (like multi-forward) if (isForwarded) { + replyLog(" RESULT: single forward from=${result.senderName}") return ParsedReplyResult( replyData = result, forwardedMessages = listOf(result) ) } + replyLog(" RESULT: reply from=${result.senderName}, text='${result.text.take(30)}'") return ParsedReplyResult(replyData = result) } else {} } else {} 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 1911a4b..aec7712 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 @@ -311,6 +311,21 @@ fun ChatsListScreen( var themeRevealToDark by remember { mutableStateOf(false) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealSnapshot by remember { mutableStateOf(null) } + var prewarmedBitmap by remember { mutableStateOf(null) } + + // Prewarm: capture bitmap on first appear + when drawer opens + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(1000) + if (prewarmedBitmap == null) { + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } + } + LaunchedEffect(drawerState.isOpen) { + if (drawerState.isOpen) { + kotlinx.coroutines.delay(200) + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } + } fun startThemeReveal() { if (themeRevealActive) { @@ -324,7 +339,10 @@ fun ChatsListScreen( val center = themeToggleCenterInRoot ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f) - val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull() + + // Use prewarmed bitmap or capture fresh + val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshotBitmap == null) { onToggleTheme() return @@ -333,6 +351,7 @@ fun ChatsListScreen( val toDark = !isDarkTheme val maxRadius = maxRevealRadius(center, rootSize) if (maxRadius <= 0f) { + snapshotBitmap.recycle() onToggleTheme() return } 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 95fcd46..2436452 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 @@ -962,6 +962,7 @@ fun MessageBubble( isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, chachaKey = message.chachaKey, + chachaKeyPlainHex = message.chachaKeyPlainHex, privateKey = privateKey, onClick = { onReplyClick(reply.messageId) }, onImageClick = onImageClick, @@ -2097,6 +2098,7 @@ fun ReplyBubble( isOutgoing: Boolean, isDarkTheme: Boolean, chachaKey: String = "", + chachaKeyPlainHex: String = "", privateKey: String = "", onClick: () -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, @@ -2224,7 +2226,10 @@ fun ReplyBubble( cacheKey = "img_${imageAttachment.id}", context = context, senderPublicKey = replyData.senderPublicKey, - recipientPrivateKey = replyData.recipientPrivateKey + recipientPrivateKey = replyData.recipientPrivateKey, + chachaKeyPlainHex = replyData.chachaKeyPlainHex.ifEmpty { + chachaKeyPlainHex + } ) if (bitmap != null) imageBitmap = bitmap } diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index 78f5e65..688ae2d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.onboarding import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -90,87 +92,17 @@ fun OnboardingScreen( // Theme transition animation var isTransitioning by remember { mutableStateOf(false) } - var transitionProgress by remember { mutableStateOf(0f) } + val transitionRadius = remember { androidx.compose.animation.core.Animatable(0f) } var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - var shouldUpdateStatusBar by remember { mutableStateOf(false) } var hasInitialized by remember { mutableStateOf(false) } var previousTheme by remember { mutableStateOf(isDarkTheme) } var targetTheme by remember { mutableStateOf(isDarkTheme) } + var rootSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { hasInitialized = true } - LaunchedEffect(isTransitioning) { - if (isTransitioning) { - shouldUpdateStatusBar = false - val duration = 800f - val startTime = System.currentTimeMillis() - while (transitionProgress < 1f) { - val elapsed = System.currentTimeMillis() - startTime - transitionProgress = (elapsed / duration).coerceAtMost(1f) - - delay(16) // ~60fps - } - // Update status bar icons after animation is completely finished - shouldUpdateStatusBar = true - delay(50) // Small delay to ensure UI updates - isTransitioning = false - transitionProgress = 0f - shouldUpdateStatusBar = false - previousTheme = targetTheme - } - } - - // Animate navigation bar color starting at 80% of wave animation val view = LocalView.current - val isGestureNavigation = remember(view.context) { - NavigationModeUtils.isGestureNavigation(view.context) - } - LaunchedEffect(isTransitioning, transitionProgress) { - if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { - val window = (view.context as android.app.Activity).window - // Map 0.8-1.0 to 0-1 for smooth interpolation - val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) - - val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF - val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF - - val r1 = (oldColor shr 16 and 0xFF) - val g1 = (oldColor shr 8 and 0xFF) - val b1 = (oldColor and 0xFF) - val r2 = (newColor shr 16 and 0xFF) - val g2 = (newColor shr 8 and 0xFF) - val b2 = (newColor and 0xFF) - - val r = (r1 + (r2 - r1) * navProgress).toInt() - val g = (g1 + (g2 - g1) * navProgress).toInt() - val b = (b1 + (b2 - b1) * navProgress).toInt() - - window.navigationBarColor = - (0xFF000000 or - (r.toLong() shl 16) or - (g.toLong() shl 8) or - b.toLong()) - .toInt() - } - } - - // Update status bar icons when animation finishes - LaunchedEffect(shouldUpdateStatusBar) { - if (shouldUpdateStatusBar && !view.isInEditMode) { - val window = (view.context as android.app.Activity).window - val insetsController = WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = false - window.statusBarColor = android.graphics.Color.TRANSPARENT - - // Navigation bar: показываем только если есть нативные кнопки - NavigationModeUtils.applyNavigationBarVisibility( - window = window, - insetsController = insetsController, - context = view.context, - isDarkTheme = isDarkTheme - ) - } - } // Set initial navigation bar color only on first launch LaunchedEffect(Unit) { @@ -221,7 +153,9 @@ fun OnboardingScreen( label = "indicatorColor" ) - Box(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { + Box(modifier = Modifier.fillMaxSize().navigationBarsPadding() + .onGloballyPositioned { rootSize = it.size } + ) { // Base background - shows the OLD theme color during transition Box( modifier = @@ -237,15 +171,11 @@ fun OnboardingScreen( // Circular reveal overlay - draws the NEW theme color expanding if (isTransitioning) { Canvas(modifier = Modifier.fillMaxSize()) { - val maxRadius = hypot(size.width, size.height) - val radius = maxRadius * transitionProgress - - // Draw the NEW theme color expanding from click point drawCircle( color = if (targetTheme) OnboardingBackground else OnboardingBackgroundLight, - radius = radius, + radius = transitionRadius.value, center = clickPosition ) } @@ -260,6 +190,22 @@ fun OnboardingScreen( clickPosition = position isTransitioning = true onThemeToggle() + scope.launch { + try { + val maxR = hypot( + rootSize.width.toFloat(), + rootSize.height.toFloat() + ).coerceAtLeast(1f) + transitionRadius.snapTo(0f) + transitionRadius.animateTo( + targetValue = maxR, + animationSpec = tween(400, easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)) + ) + } finally { + isTransitioning = false + previousTheme = targetTheme + } + } } }, modifier = 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 2517118..77ecebd 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 @@ -132,6 +132,13 @@ fun MyQrCodeScreen( var rootSize by remember { mutableStateOf(IntSize.Zero) } var lastRevealTime by remember { mutableLongStateOf(0L) } val revealCooldownMs = 600L + var prewarmedBitmap by remember { mutableStateOf(null) } + + // Prewarm bitmap on screen appear + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(300) + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } fun startReveal(newIndex: Int, center: Offset) { val now = System.currentTimeMillis() @@ -142,7 +149,8 @@ fun MyQrCodeScreen( return } - val snapshot = runCatching { view.drawToBitmap() }.getOrNull() + val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshot == null) { selectedThemeIndex = newIndex return @@ -304,7 +312,8 @@ fun MyQrCodeScreen( val now = System.currentTimeMillis() if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) { lastRevealTime = now - val snapshot = runCatching { view.drawToBitmap() }.getOrNull() + val snapshot = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshot != null) { val maxR = maxRevealRadius(themeButtonPos, rootSize) revealActive = true diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index d352958..c0f9b31 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -99,6 +99,13 @@ fun ThemeScreen( var themeRevealToDark by remember { mutableStateOf(false) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealSnapshot by remember { mutableStateOf(null) } + var prewarmedBitmap by remember { mutableStateOf(null) } + + // Prewarm bitmap on screen appear + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(300) + prewarmedBitmap = runCatching { view.drawToBitmap() }.getOrNull() + } var lightOptionCenter by remember { mutableStateOf(null) } var darkOptionCenter by remember { mutableStateOf(null) } var systemOptionCenter by remember { mutableStateOf(null) } @@ -130,7 +137,8 @@ fun ThemeScreen( val center = centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f) - val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull() + val snapshotBitmap = prewarmedBitmap ?: runCatching { view.drawToBitmap() }.getOrNull() + prewarmedBitmap = null if (snapshotBitmap == null) { themeMode = targetMode onThemeModeChange(targetMode) From 5e5c4c11ac05b8207880177e367e79e50978dfa6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 02:06:15 +0500 Subject: [PATCH 02/32] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20Telegram-=D0=BF=D0=BE=D0=B4=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20UI=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/network/AttachmentType.kt | 1 + .../messenger/ui/chats/ChatDetailScreen.kt | 16 + .../messenger/ui/chats/ChatViewModel.kt | 193 ++++- .../messenger/ui/chats/ChatsListScreen.kt | 2 + .../messenger/ui/chats/ChatsListViewModel.kt | 36 +- .../chats/components/AttachmentComponents.kt | 596 +++++++++++++- .../chats/components/ChatDetailComponents.kt | 1 + .../ui/chats/input/ChatDetailInput.kt | 761 ++++++++++++++++-- 8 files changed, 1511 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt index a6e610d..7500650 100644 --- a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt +++ b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt @@ -9,6 +9,7 @@ enum class AttachmentType(val value: Int) { FILE(2), // Файл AVATAR(3), // Аватар пользователя CALL(4), // Событие звонка (пропущен/принят/завершен) + VOICE(5), // Голосовое сообщение UNKNOWN(-1); // Неизвестный тип companion object { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 5c084a2..fc7c39c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2679,6 +2679,20 @@ fun ChatDetailScreen( isSendingMessage = false } }, + onSendVoiceMessage = { voiceHex, durationSec, waves -> + isSendingMessage = true + viewModel.sendVoiceMessage( + voiceHex = voiceHex, + durationSec = durationSec, + waves = waves + ) + scope.launch { + delay(120) + listState.animateScrollToItem(0) + delay(220) + isSendingMessage = false + } + }, isDarkTheme = isDarkTheme, backgroundColor = backgroundColor, textColor = textColor, @@ -4258,6 +4272,7 @@ private fun ChatInputBarSection( viewModel: ChatViewModel, isSavedMessages: Boolean, onSend: () -> Unit, + onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List) -> Unit, isDarkTheme: Boolean, backgroundColor: Color, textColor: Color, @@ -4295,6 +4310,7 @@ private fun ChatInputBarSection( } }, onSend = onSend, + onSendVoiceMessage = onSendVoiceMessage, isDarkTheme = isDarkTheme, backgroundColor = backgroundColor, textColor = textColor, 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 d557597..d8dfe1e 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 @@ -1625,6 +1625,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { "file" -> AttachmentType.FILE.value "avatar" -> AttachmentType.AVATAR.value "call" -> AttachmentType.CALL.value + "voice" -> AttachmentType.VOICE.value else -> -1 } } @@ -1792,9 +1793,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) ) - // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой + // 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой if ((effectiveType == AttachmentType.IMAGE || - effectiveType == AttachmentType.AVATAR) && + effectiveType == AttachmentType.AVATAR || + effectiveType == AttachmentType.VOICE) && blob.isEmpty() && attachmentId.isNotEmpty() ) { @@ -2566,6 +2568,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { message.attachments.any { it.type == AttachmentType.FILE } -> "File" message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar" message.attachments.any { it.type == AttachmentType.CALL } -> "Call" + message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message" message.forwardedMessages.isNotEmpty() -> "Forwarded message" message.replyData != null -> "Reply" else -> "Pinned message" @@ -4806,6 +4809,192 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * 🎙️ Отправка голосового сообщения. + * blob хранится как HEX строка opus/webm байт (desktop parity). + * preview формат: "::" + */ + fun sendVoiceMessage( + voiceHex: String, + durationSec: Int, + waves: List + ) { + val recipient = opponentKey + val sender = myPublicKey + val privateKey = myPrivateKey + + if (recipient == null || sender == null || privateKey == null) { + return + } + if (isSending) { + return + } + + val normalizedVoiceHex = voiceHex.trim() + if (normalizedVoiceHex.isEmpty()) { + return + } + + val normalizedDuration = durationSec.coerceAtLeast(1) + val normalizedWaves = + waves.asSequence() + .map { it.coerceIn(0f, 1f) } + .take(120) + .toList() + val wavesPreview = + normalizedWaves.joinToString(",") { + String.format(Locale.US, "%.3f", it) + } + val preview = "$normalizedDuration::$wavesPreview" + + isSending = true + + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) + val timestamp = System.currentTimeMillis() + val attachmentId = "voice_$timestamp" + + // 1. 🚀 Optimistic UI + val optimisticMessage = + ChatMessage( + id = messageId, + text = "", + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = + listOf( + MessageAttachment( + id = attachmentId, + type = AttachmentType.VOICE, + preview = preview, + blob = normalizedVoiceHex + ) + ) + ) + addMessageSafely(optimisticMessage) + _inputText.value = "" + + viewModelScope.launch(Dispatchers.IO) { + try { + val encryptionContext = + buildEncryptionContext( + plaintext = "", + recipient = recipient, + privateKey = privateKey + ) ?: throw IllegalStateException("Cannot resolve chat encryption context") + val encryptedContent = encryptionContext.encryptedContent + val encryptedKey = encryptionContext.encryptedKey + val aesChachaKey = encryptionContext.aesChachaKey + + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + + val encryptedVoiceBlob = + encryptAttachmentPayload(normalizedVoiceHex, encryptionContext) + + val isSavedMessages = (sender == recipient) + var uploadTag = "" + if (!isSavedMessages) { + uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob) + } + val attachmentTransportServer = + if (uploadTag.isNotEmpty()) { + TransportManager.getTransportServer().orEmpty() + } else { + "" + } + + val voiceAttachment = + MessageAttachment( + id = attachmentId, + blob = "", + type = AttachmentType.VOICE, + preview = preview, + transportTag = uploadTag, + transportServer = attachmentTransportServer + ) + + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.aesChachaKey = aesChachaKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(voiceAttachment) + } + + if (!isSavedMessages) { + ProtocolManager.send(packet) + } + + // Для отправителя сохраняем voice blob локально в encrypted cache. + runCatching { + AttachmentFileManager.saveAttachment( + context = getApplication(), + blob = normalizedVoiceHex, + attachmentId = attachmentId, + publicKey = sender, + privateKey = privateKey + ) + } + + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.VOICE.value) + put("preview", preview) + put("blob", "") + put("transportTag", uploadTag) + put("transportServer", attachmentTransportServer) + } + ) + } + .toString() + + saveMessageToDatabase( + messageId = messageId, + text = "", + encryptedContent = encryptedContent, + encryptedKey = + if (encryptionContext.isGroup) { + buildStoredGroupKey( + encryptionContext.attachmentPassword, + privateKey + ) + } else { + encryptedKey + }, + timestamp = timestamp, + isFromMe = true, + delivered = if (isSavedMessages) 1 else 0, + attachmentsJson = attachmentsJson + ) + + withContext(Dispatchers.Main) { + if (isSavedMessages) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + } + + saveDialog("Voice message", timestamp) + } catch (_: Exception) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) + saveDialog("Voice message", timestamp) + } finally { + isSending = false + } + } + } + /** * Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение */ 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 aec7712..8c6e7d0 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 @@ -4549,6 +4549,8 @@ fun DialogItemContent( "Avatar" -> "Avatar" dialog.lastMessageAttachmentType == "Call" -> "Call" + dialog.lastMessageAttachmentType == + "Voice message" -> "Voice message" dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 7f9ee46..a06d219 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -573,16 +573,50 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio lastMessageAttachments: String ): String? { val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments) - return when (attachmentType) { + val effectiveType = + if (attachmentType >= 0) attachmentType + else inferAttachmentTypeFromJson(lastMessageAttachments) + + return when (effectiveType) { 0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0 1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward) 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 4 -> "Call" // AttachmentType.CALL = 4 + 5 -> "Voice message" // AttachmentType.VOICE = 5 else -> if (inferredCall) "Call" else null } } + private fun inferAttachmentTypeFromJson(rawAttachments: String): Int { + if (rawAttachments.isBlank() || rawAttachments == "[]") return -1 + return try { + val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1 + if (attachments.length() <= 0) return -1 + val first = attachments.optJSONObject(0) ?: return -1 + val rawType = first.opt("type") + when (rawType) { + is Number -> rawType.toInt() + is String -> { + val normalized = rawType.trim() + normalized.toIntOrNull() + ?: when (normalized.lowercase(Locale.ROOT)) { + "image" -> 0 + "messages", "reply", "forward" -> 1 + "file" -> 2 + "avatar" -> 3 + "call" -> 4 + "voice" -> 5 + else -> -1 + } + } + else -> -1 + } + } catch (_: Throwable) { + -1 + } + } + private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean { if (rawAttachments.isBlank() || rawAttachments == "[]") return false return try { 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 b355d91..720f5f0 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 @@ -1,10 +1,15 @@ package com.rosetta.messenger.ui.chats.components +import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.os.SystemClock import android.util.Base64 import android.util.LruCache +import android.webkit.MimeTypeMap import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -61,27 +66,31 @@ import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.repository.AvatarRepository +import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.chats.models.MessageStatus import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AvatarFileManager import com.vanniktech.blurhash.BlurHash -import com.rosetta.messenger.ui.icons.TelegramIcons +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext -import androidx.compose.ui.platform.LocalConfiguration -import androidx.core.content.FileProvider -import android.content.Intent -import android.os.SystemClock -import android.webkit.MimeTypeMap import java.io.ByteArrayInputStream import java.io.File import java.security.MessageDigest import kotlin.math.min +import androidx.compose.ui.platform.LocalConfiguration +import androidx.core.content.FileProvider private const val TAG = "AttachmentComponents" private const val MAX_BITMAP_DECODE_DIMENSION = 4096 @@ -126,6 +135,96 @@ private fun decodeBase64Payload(data: String): ByteArray? { } } +private fun decodeHexPayload(data: String): ByteArray? { + val raw = data.trim().removePrefix("0x") + if (raw.isBlank() || raw.length % 2 != 0) return null + if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null + return runCatching { + ByteArray(raw.length / 2) { index -> + raw.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + }.getOrNull() +} + +private fun decodeVoicePayload(data: String): ByteArray? { + return decodeHexPayload(data) ?: decodeBase64Payload(data) +} + +private object VoicePlaybackCoordinator { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + 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 _positionMs = MutableStateFlow(0) + val positionMs: StateFlow = _positionMs.asStateFlow() + private val _durationMs = MutableStateFlow(0) + val durationMs: StateFlow = _durationMs.asStateFlow() + + fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) { + if (!sourceFile.exists()) { + onError("Voice file is missing") + return + } + if (currentAttachmentId == attachmentId && player?.isPlaying == true) { + stop() + return + } + + stop() + val mediaPlayer = MediaPlayer() + try { + mediaPlayer.setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + mediaPlayer.setDataSource(sourceFile.absolutePath) + mediaPlayer.setOnCompletionListener { stop() } + mediaPlayer.prepare() + mediaPlayer.start() + player = mediaPlayer + currentAttachmentId = attachmentId + _playingAttachmentId.value = attachmentId + _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) + } + } + } catch (e: Exception) { + runCatching { mediaPlayer.release() } + stop() + onError(e.message ?: "Playback failed") + } + } + + fun stop() { + val active = player + player = null + currentAttachmentId = null + progressJob?.cancel() + progressJob = null + _playingAttachmentId.value = null + _positionMs.value = 0 + _durationMs.value = 0 + if (active != null) { + runCatching { + if (active.isPlaying) active.stop() + } + runCatching { active.release() } + } + } +} + private fun shortDebugId(value: String): String { if (value.isBlank()) return "empty" val clean = value.trim() @@ -486,6 +585,7 @@ private fun TelegramFileActionButton( fun MessageAttachments( attachments: List, chachaKey: String, + chachaKeyPlainHex: String = "", privateKey: String, isOutgoing: Boolean, isDarkTheme: Boolean, @@ -573,6 +673,19 @@ fun MessageAttachments( isDarkTheme = isDarkTheme ) } + AttachmentType.VOICE -> { + VoiceAttachment( + attachment = attachment, + chachaKey = chachaKey, + chachaKeyPlainHex = chachaKeyPlainHex, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus + ) + } else -> { // Desktop parity: unsupported/legacy attachment gets explicit compatibility card. LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme) @@ -1679,6 +1792,60 @@ private fun parseCallDurationSeconds(preview: String): Int { return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0 } +private fun parseVoicePreview(preview: String): Pair> { + if (preview.isBlank()) return 0 to emptyList() + val durationPart = preview.substringBefore("::", preview).trim() + val wavesPart = preview.substringAfter("::", "").trim() + val duration = durationPart.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val waves = + if (wavesPart.isBlank()) { + emptyList() + } else { + wavesPart.split(",") + .mapNotNull { it.trim().toFloatOrNull() } + .map { it.coerceIn(0f, 1f) } + } + return duration to waves +} + +private fun normalizeVoiceWaves(source: List, targetLength: Int): List { + if (targetLength <= 0) return emptyList() + if (source.isEmpty()) return List(targetLength) { 0f } + if (source.size == targetLength) return source + if (source.size > targetLength) { + val bucket = source.size.toFloat() / targetLength.toFloat() + return List(targetLength) { idx -> + val start = kotlin.math.floor(idx * bucket).toInt() + val end = kotlin.math.max(start + 1, kotlin.math.floor((idx + 1) * bucket).toInt()) + var maxValue = 0f + var i = start + while (i < end && i < source.size) { + if (source[i] > maxValue) maxValue = source[i] + i++ + } + maxValue + } + } + if (targetLength == 1) return listOf(source.first()) + val lastIndex = source.lastIndex.toFloat() + return List(targetLength) { idx -> + val pos = idx * lastIndex / (targetLength - 1).toFloat() + val left = kotlin.math.floor(pos).toInt() + val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex) + if (left == right) source[left] else { + val t = pos - left.toFloat() + source[left] * (1f - t) + source[right] * t + } + } +} + +private fun formatVoiceDuration(seconds: Int): String { + val safe = seconds.coerceAtLeast(0) + val minutes = (safe / 60).toString().padStart(2, '0') + val rem = (safe % 60).toString().padStart(2, '0') + return "$minutes:$rem" +} + private fun formatDesktopCallDuration(durationSec: Int): String { val minutes = durationSec / 60 val seconds = durationSec % 60 @@ -1790,6 +1957,369 @@ fun CallAttachment( } } +private fun ensureVoiceAudioFile( + context: android.content.Context, + attachmentId: String, + payload: String +): File? { + val bytes = decodeVoicePayload(payload) ?: return null + val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() } + val file = File(directory, "$attachmentId.webm") + runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null + return file +} + +@Composable +private fun VoiceAttachment( + attachment: MessageAttachment, + chachaKey: String, + chachaKeyPlainHex: String, + privateKey: String, + senderPublicKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + timestamp: java.util.Date, + messageStatus: MessageStatus = MessageStatus.READ +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState() + val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState() + val isPlaying = playingAttachmentId == attachment.id + + val (previewDurationSecRaw, previewWavesRaw) = + remember(attachment.preview) { parseVoicePreview(attachment.preview) } + val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1) + val previewWaves = + remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) } + val waves = + remember(previewWaves) { + if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves + } + + var payload by + remember(attachment.id, attachment.blob) { + mutableStateOf(attachment.blob.trim()) + } + var downloadStatus by + remember(attachment.id, attachment.blob, attachment.transportTag) { + mutableStateOf( + when { + attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED + attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED + else -> DownloadStatus.ERROR + } + ) + } + var errorText by remember { mutableStateOf("") } + var audioFilePath by remember(attachment.id) { mutableStateOf(null) } + + val effectiveDurationSec = + remember(isPlaying, playbackDurationMs, previewDurationSec) { + val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0) + if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec + } + val progress = + if (isPlaying && playbackDurationMs > 0) { + (playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + val timeText = + if (isPlaying && playbackDurationMs > 0) { + val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) + "-${formatVoiceDuration(leftSec)}" + } else { + formatVoiceDuration(effectiveDurationSec) + } + + LaunchedEffect(payload, attachment.id) { + if (payload.isBlank()) return@LaunchedEffect + val prepared = ensureVoiceAudioFile(context, attachment.id, payload) + if (prepared != null) { + audioFilePath = prepared.absolutePath + if (downloadStatus != DownloadStatus.DOWNLOADING && + downloadStatus != DownloadStatus.DECRYPTING + ) { + downloadStatus = DownloadStatus.DOWNLOADED + } + if (errorText.isNotBlank()) errorText = "" + } else { + audioFilePath = null + downloadStatus = DownloadStatus.ERROR + if (errorText.isBlank()) errorText = "Cannot decode voice" + } + } + + DisposableEffect(attachment.id) { + onDispose { + if (playingAttachmentId == attachment.id) { + VoicePlaybackCoordinator.stop() + } + } + } + + val triggerDownload: () -> Unit = download@{ + if (attachment.transportTag.isBlank()) { + downloadStatus = DownloadStatus.ERROR + errorText = "Voice not available" + return@download + } + scope.launch { + downloadStatus = DownloadStatus.DOWNLOADING + errorText = "" + val decrypted = + downloadAndDecryptVoicePayload( + attachmentId = attachment.id, + downloadTag = attachment.transportTag, + chachaKey = chachaKey, + privateKey = privateKey, + transportServer = attachment.transportServer, + chachaKeyPlainHex = chachaKeyPlainHex + ) + if (decrypted.isNullOrBlank()) { + downloadStatus = DownloadStatus.ERROR + errorText = "Failed to decrypt" + return@launch + } + val saved = + runCatching { + AttachmentFileManager.saveAttachment( + context = context, + blob = decrypted, + attachmentId = attachment.id, + publicKey = senderPublicKey, + privateKey = privateKey + ) + } + .getOrDefault(false) + payload = decrypted + if (!saved) { + // Не блокируем UI, но оставляем маркер в логе. + runCatching { android.util.Log.w(TAG, "Voice cache save failed: ${attachment.id}") } + } + downloadStatus = DownloadStatus.DOWNLOADED + } + } + + val onMainAction: () -> Unit = { + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload() + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit + DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> { + val file = audioFilePath?.let { File(it) } + 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 -> + downloadStatus = DownloadStatus.ERROR + errorText = message + } + } else { + downloadStatus = DownloadStatus.ERROR + errorText = "Cannot decode voice" + } + } else { + triggerDownload() + } + } else { + VoicePlaybackCoordinator.toggle(attachment.id, file) { message -> + downloadStatus = DownloadStatus.ERROR + errorText = message + } + } + } + } + } + + val barInactiveColor = + if (isOutgoing) Color.White.copy(alpha = 0.38f) + else if (isDarkTheme) Color(0xFF5D6774) + else Color(0xFFB6C0CC) + val barActiveColor = if (isOutgoing) Color.White else PrimaryBlue + val secondaryTextColor = + if (isOutgoing) Color.White.copy(alpha = 0.72f) + else if (isDarkTheme) Color(0xFF9EAABD) + else Color(0xFF667283) + + val actionBackground = + when (downloadStatus) { + DownloadStatus.ERROR -> Color(0xFFE55757) + else -> if (isOutgoing) Color.White.copy(alpha = 0.2f) else PrimaryBlue + } + val actionTint = + when { + downloadStatus == DownloadStatus.ERROR -> Color.White + isOutgoing -> Color.White + else -> Color.White + } + + Row( + modifier = + Modifier.fillMaxWidth() + .padding(vertical = 4.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onMainAction() }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background(actionBackground), + contentAlignment = Alignment.Center + ) { + if (downloadStatus == DownloadStatus.DOWNLOADING || + downloadStatus == DownloadStatus.DECRYPTING + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + color = Color.White, + strokeWidth = 2.2.dp + ) + } else { + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> { + Icon( + painter = painterResource(R.drawable.msg_download), + contentDescription = null, + tint = actionTint, + modifier = Modifier.size(20.dp) + ) + } + DownloadStatus.ERROR -> { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = actionTint, + modifier = Modifier.size(20.dp) + ) + } + else -> { + Icon( + imageVector = + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = null, + tint = actionTint, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth().height(28.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + waves.forEachIndexed { index, value -> + val normalized = value.coerceIn(0f, 1f) + val passed = (progress * waves.size) - index + val fill = passed.coerceIn(0f, 1f) + val color = + if (fill > 0f) { + barActiveColor + } else { + barInactiveColor + } + Box( + modifier = + Modifier.width(2.dp) + .height((4f + normalized * 18f).dp) + .clip(RoundedCornerShape(100)) + .background(color) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = + if (downloadStatus == DownloadStatus.ERROR && errorText.isNotBlank()) + errorText + else timeText, + fontSize = 12.sp, + color = + if (downloadStatus == DownloadStatus.ERROR) { + Color(0xFFE55757) + } else { + secondaryTextColor + } + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), + fontSize = 11.sp, + color = secondaryTextColor + ) + + if (isOutgoing) { + when (messageStatus) { + MessageStatus.SENDING -> { + Icon( + painter = TelegramIcons.Clock, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + } + MessageStatus.SENT, + MessageStatus.DELIVERED -> { + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + } + MessageStatus.READ -> { + Box(modifier = Modifier.height(14.dp)) { + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp).offset(x = 4.dp) + ) + } + } + MessageStatus.ERROR -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = Color(0xFFE55757), + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } + } +} + /** File attachment - Telegram style */ @Composable fun FileAttachment( @@ -2933,6 +3463,60 @@ private suspend fun processDownloadedImage( } } +/** + * CDN download + decrypt helper for voice payload (hex string). + */ +internal suspend fun downloadAndDecryptVoicePayload( + attachmentId: String, + downloadTag: String, + chachaKey: String, + privateKey: String, + transportServer: String = "", + chachaKeyPlainHex: String = "" +): String? { + if (downloadTag.isBlank() || privateKey.isBlank()) return null + if (chachaKeyPlainHex.isBlank() && chachaKey.isBlank()) return null + + return withContext(Dispatchers.IO) { + runCatching { + val encryptedContent = + TransportManager.downloadFile( + attachmentId, + downloadTag, + transportServer.ifBlank { null } + ) + if (encryptedContent.isBlank()) return@withContext null + + when { + chachaKeyPlainHex.isNotBlank() -> { + val plainKey = + chachaKeyPlainHex.chunked(2) + .mapNotNull { part -> part.toIntOrNull(16)?.toByte() } + .toByteArray() + if (plainKey.isEmpty()) { + null + } else { + MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey) + ?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey) + .takeIf { it.isNotEmpty() } + } + } + isGroupStoredKey(chachaKey) -> { + val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null + CryptoManager.decryptWithPassword(encryptedContent, groupPassword) + ?: run { + val hexKey = + groupPassword.toByteArray(Charsets.ISO_8859_1) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + CryptoManager.decryptWithPassword(encryptedContent, hexKey) + } + } + else -> MessageCrypto.decryptAttachmentBlob(encryptedContent, chachaKey, privateKey) + } + }.getOrNull() + } +} + /** * CDN download + decrypt + cache + save. * Shared between ReplyBubble and ForwardedImagePreview. 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 2436452..56310ee 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 @@ -979,6 +979,7 @@ fun MessageBubble( MessageAttachments( attachments = message.attachments, chachaKey = message.chachaKey, + chachaKeyPlainHex = message.chachaKeyPlainHex, privateKey = privateKey, isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, 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 29da07e..96ed95e 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 @@ -1,9 +1,19 @@ package com.rosetta.messenger.ui.chats.input +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.os.Build import android.view.inputmethod.InputMethodManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Mic import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -34,6 +44,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -44,6 +56,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator +import androidx.core.content.ContextCompat import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.network.AttachmentType @@ -60,7 +73,11 @@ import com.rosetta.messenger.ui.chats.ChatViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import java.io.File import java.util.Locale +import java.util.UUID +import kotlin.math.PI +import kotlin.math.sin private fun truncateEmojiSafe(text: String, maxLen: Int): String { if (text.length <= maxLen) return text @@ -75,6 +92,284 @@ private fun truncateEmojiSafe(text: String, maxLen: Int): String { return text.substring(0, cutAt).trimEnd() + "..." } +private fun bytesToHexLower(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + val out = StringBuilder(bytes.size * 2) + bytes.forEach { out.append(String.format("%02x", it.toInt() and 0xff)) } + return out.toString() +} + +private fun compressVoiceWaves(source: List, targetLength: Int): List { + if (targetLength <= 0) return emptyList() + if (source.isEmpty()) return List(targetLength) { 0f } + if (source.size == targetLength) return source + + if (source.size > targetLength) { + val bucketSize = source.size / targetLength.toFloat() + return List(targetLength) { index -> + val start = kotlin.math.floor(index * bucketSize).toInt() + val end = kotlin.math.max(start + 1, kotlin.math.floor((index + 1) * bucketSize).toInt()) + var maxValue = 0f + for (i in start until end.coerceAtMost(source.size)) { + if (source[i] > maxValue) maxValue = source[i] + } + maxValue + } + } + + if (targetLength == 1) return listOf(source.first()) + + val lastIndex = source.lastIndex.toFloat() + return List(targetLength) { index -> + val pos = index * lastIndex / (targetLength - 1).toFloat() + val left = kotlin.math.floor(pos).toInt() + val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex) + if (left == right) { + source[left] + } else { + val t = pos - left.toFloat() + source[left] * (1f - t) + source[right] * t + } + } +} + +private fun formatVoiceRecordTimer(elapsedMs: Long): String { + val safeTenths = (elapsedMs.coerceAtLeast(0L) / 100L).toInt() + val totalSeconds = safeTenths / 10 + val tenths = safeTenths % 10 + val minutes = totalSeconds / 60 + val seconds = (totalSeconds % 60).toString().padStart(2, '0') + return "$minutes:$seconds,$tenths" +} + +@Composable +private fun RecordBlinkDot( + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + + val enterScale by animateFloatAsState( + targetValue = if (entered) 1f else 0f, + animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing), + label = "record_dot_enter_scale" + ) + val blinkAlpha by rememberInfiniteTransition(label = "record_dot_blink").animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 600, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "record_dot_alpha" + ) + + val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) + Box( + modifier = modifier + .size(28.dp) + .graphicsLayer { + scaleX = enterScale + scaleY = enterScale + alpha = blinkAlpha + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(10.dp) // Telegram fallback dot radius = 5dp + .clip(CircleShape) + .background(dotColor) + ) + } +} + +@Composable +private fun VoiceMovingBlob( + voiceLevel: Float, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val rawLevel = voiceLevel.coerceIn(0f, 1f) + val level by animateFloatAsState( + targetValue = rawLevel, + animationSpec = tween(durationMillis = 100, easing = LinearOutSlowInEasing), + label = "voice_blob_level" + ) + val transition = rememberInfiniteTransition(label = "voice_blob_motion") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "voice_blob_phase" + ) + + val waveColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) + Canvas( + modifier = modifier + .width(36.dp) + .height(18.dp) + ) { + val cxBase = size.width * 0.5f + val cy = size.height * 0.5f + val waveShift = (size.width * 0.16f * sin(phase * 2f * PI.toFloat())) + val cx = cxBase + waveShift + val base = size.minDimension * 0.22f + + drawCircle( + color = waveColor.copy(alpha = 0.2f + level * 0.15f), + radius = base * (2.1f + level * 1.0f), + center = Offset(cx - waveShift * 0.55f, cy) + ) + drawCircle( + color = waveColor.copy(alpha = 0.38f + level * 0.20f), + radius = base * (1.55f + level * 0.75f), + center = Offset(cx + waveShift * 0.35f, cy) + ) + drawCircle( + color = waveColor.copy(alpha = 0.95f), + radius = base * (0.88f + level * 0.15f), + center = Offset(cx, cy) + ) + } +} + +@Composable +private fun VoiceButtonBlob( + voiceLevel: Float, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val rawLevel = voiceLevel.coerceIn(0f, 1f) + val bigLevel by animateFloatAsState( + targetValue = rawLevel, + animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing), + label = "voice_btn_blob_big_level" + ) + val smallLevel by animateFloatAsState( + targetValue = rawLevel * 0.88f, + animationSpec = tween(durationMillis = 220, easing = LinearOutSlowInEasing), + label = "voice_btn_blob_small_level" + ) + val transition = rememberInfiniteTransition(label = "voice_btn_blob_motion") + val morphPhase by transition.animateFloat( + initialValue = 0f, + targetValue = (2f * PI.toFloat()), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2200, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "voice_btn_blob_morph" + ) + val driftX by transition.animateFloat( + initialValue = -1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1650, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "voice_btn_blob_drift_x" + ) + val driftY by transition.animateFloat( + initialValue = 1f, + targetValue = -1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1270, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "voice_btn_blob_drift_y" + ) + + val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF) + + fun createBlobPath( + center: Offset, + baseRadius: Float, + points: Int, + phase: Float, + seed: Float, + level: Float, + formMax: Float + ): Path { + val coords = ArrayList(points) + val step = (2f * PI.toFloat()) / points.toFloat() + val deform = (0.08f + formMax * 0.34f) * level + for (i in 0 until points) { + val angle = i * step + val p = angle + phase * (0.6f + seed * 0.22f) + val n1 = sin(p * (2.2f + seed * 0.45f)) + val n2 = sin(p * (3.4f + seed * 0.28f) - phase * (0.7f + seed * 0.18f)) + val mix = n1 * 0.65f + n2 * 0.35f + val radius = baseRadius * (1f + mix * deform) + coords += Offset( + x = center.x + radius * kotlin.math.cos(angle), + y = center.y + radius * kotlin.math.sin(angle) + ) + } + + val path = Path() + if (coords.isEmpty()) return path + val firstMid = Offset( + (coords.last().x + coords.first().x) * 0.5f, + (coords.last().y + coords.first().y) * 0.5f + ) + path.moveTo(firstMid.x, firstMid.y) + for (i in coords.indices) { + val current = coords[i] + val next = coords[(i + 1) % coords.size] + val mid = Offset((current.x + next.x) * 0.5f, (current.y + next.y) * 0.5f) + path.quadraticBezierTo(current.x, current.y, mid.x, mid.y) + } + path.close() + return path + } + + Canvas(modifier = modifier) { + val center = Offset( + x = size.width * 0.5f + size.width * 0.05f * driftX, + y = size.height * 0.5f + size.height * 0.04f * driftY + ) + val baseRadius = size.minDimension * 0.25f + + // Telegram-like constants: + // SCALE_BIG_MIN=0.878, SCALE_SMALL_MIN=0.926, +1.4*amplitude + val bigScale = 0.878f + 1.4f * bigLevel + val smallScale = 0.926f + 1.4f * smallLevel + + val bigPath = createBlobPath( + center = center, + baseRadius = baseRadius * bigScale, + points = 12, + phase = morphPhase, + seed = 0.23f, + level = bigLevel, + formMax = 0.6f + ) + val smallPath = createBlobPath( + center = Offset(center.x - size.width * 0.01f, center.y + size.height * 0.01f), + baseRadius = baseRadius * smallScale, + points = 11, + phase = morphPhase + 0.7f, + seed = 0.61f, + level = smallLevel, + formMax = 0.6f + ) + + drawPath( + path = bigPath, + color = blobColor.copy(alpha = 0.30f) + ) + drawPath( + path = smallPath, + color = blobColor.copy(alpha = 0.15f) + ) + } +} + /** * Message input bar and related components * Extracted from ChatDetailScreen.kt for better organization @@ -102,6 +397,7 @@ fun MessageInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, + onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List) -> Unit = { _, _, _ -> }, isDarkTheme: Boolean, backgroundColor: Color, textColor: Color, @@ -245,6 +541,156 @@ fun MessageInputBar( } } + var voiceRecorder by remember { mutableStateOf(null) } + var voiceOutputFile by remember { mutableStateOf(null) } + var isVoiceRecording by remember { mutableStateOf(false) } + var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } + var voiceElapsedMs by remember { mutableLongStateOf(0L) } + var voiceWaves by remember { mutableStateOf>(emptyList()) } + + fun stopVoiceRecording(send: Boolean) { + val recorder = voiceRecorder + val outputFile = voiceOutputFile + val elapsedSnapshot = + if (voiceRecordStartedAtMs > 0L) { + maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs) + } else { + voiceElapsedMs + } + val durationSnapshot = ((elapsedSnapshot + 999L) / 1000L).toInt().coerceAtLeast(1) + val wavesSnapshot = voiceWaves + + voiceRecorder = null + voiceOutputFile = null + isVoiceRecording = false + voiceRecordStartedAtMs = 0L + voiceElapsedMs = 0L + voiceWaves = emptyList() + + var recordedOk = false + if (recorder != null) { + recordedOk = runCatching { + recorder.stop() + true + }.getOrDefault(false) + runCatching { recorder.reset() } + runCatching { recorder.release() } + } + + if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) { + val voiceHex = + runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("") + if (voiceHex.isNotBlank()) { + onSendVoiceMessage( + voiceHex, + durationSnapshot, + compressVoiceWaves(wavesSnapshot, 35) + ) + } + } + runCatching { outputFile?.delete() } + } + + fun startVoiceRecording() { + if (isVoiceRecording) return + + try { + val voiceDir = File(context.cacheDir, "voice_recordings").apply { mkdirs() } + val output = File(voiceDir, "voice_${UUID.randomUUID()}.webm") + + val recorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.WEBM) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) + recorder.setAudioEncodingBitRate(32_000) + recorder.setAudioSamplingRate(48_000) + recorder.setOutputFile(output.absolutePath) + recorder.prepare() + recorder.start() + + voiceRecorder = recorder + voiceOutputFile = output + voiceRecordStartedAtMs = System.currentTimeMillis() + voiceElapsedMs = 0L + voiceWaves = emptyList() + isVoiceRecording = true + + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus(force = true) + } catch (_: Exception) { + stopVoiceRecording(send = false) + android.widget.Toast.makeText( + context, + "Voice recording is not supported on this device", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + + val recordAudioPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + startVoiceRecording() + } else { + android.widget.Toast.makeText( + context, + "Microphone permission is required for voice messages", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + + fun requestVoiceRecording() { + val granted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + startVoiceRecording() + } else { + recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + + LaunchedEffect(isVoiceRecording, voiceRecorder) { + if (!isVoiceRecording) return@LaunchedEffect + while (isVoiceRecording && voiceRecorder != null) { + if (voiceRecordStartedAtMs > 0L) { + voiceElapsedMs = + (System.currentTimeMillis() - voiceRecordStartedAtMs).coerceAtLeast(0L) + } + delay(100) + } + } + + LaunchedEffect(isVoiceRecording, voiceRecorder) { + if (!isVoiceRecording) return@LaunchedEffect + while (isVoiceRecording && voiceRecorder != null) { + val amplitude = runCatching { voiceRecorder?.maxAmplitude ?: 0 }.getOrDefault(0) + val normalized = (amplitude.toFloat() / 32_767f).coerceIn(0f, 1f) + voiceWaves = (voiceWaves + normalized).takeLast(120) + delay(90) + } + } + + DisposableEffect(Unit) { + onDispose { + if (isVoiceRecording) { + stopVoiceRecording(send = false) + } + } + } + val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } var isSending by remember { mutableStateOf(false) } var showForwardCancelDialog by remember { mutableStateOf(false) } @@ -329,6 +775,9 @@ fun MessageInputBar( val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus(force = true) + if (isVoiceRecording) { + stopVoiceRecording(send = false) + } } } @@ -339,7 +788,7 @@ fun MessageInputBar( } fun toggleEmojiPicker() { - if (suppressKeyboard) return + if (suppressKeyboard || isVoiceRecording) return val currentTime = System.currentTimeMillis() val timeSinceLastToggle = currentTime - lastToggleTime @@ -389,6 +838,10 @@ fun MessageInputBar( } fun handleSend() { + if (isVoiceRecording) { + stopVoiceRecording(send = true) + return + } if (value.isNotBlank() || hasReply) { isSending = true onSend() @@ -870,93 +1323,229 @@ fun MessageInputBar( } } - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom - ) { - IconButton( - onClick = onAttachClick, - modifier = Modifier.size(40.dp) - ) { - Icon( - painter = TelegramIcons.Attach, - contentDescription = "Attach", - tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) - else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - Box( - modifier = Modifier - .weight(1f) - .heightIn(min = 40.dp, max = 150.dp) - .background(color = backgroundColor) - .padding(horizontal = 12.dp, vertical = 8.dp), - contentAlignment = Alignment.TopStart - ) { - AppleEmojiTextField( - value = value, - onValueChange = { newValue -> onValueChange(newValue) }, - textColor = textColor, - textSize = 16f, - hint = "Type message...", - hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), - modifier = Modifier.fillMaxWidth(), - requestFocus = hasReply, - onViewCreated = { view -> editTextView = view }, - onFocusChanged = { hasFocus -> - if (hasFocus && showEmojiPicker) { - onToggleEmojiPicker(false) - } - }, - onSelectionChanged = { start, end -> - selectionStart = start - selectionEnd = end + if (isVoiceRecording) { + val recordingPanelColor = + if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) + val recordingTextColor = + if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37) + val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f } + var recordUiEntered by remember { mutableStateOf(false) } + LaunchedEffect(isVoiceRecording) { + if (isVoiceRecording) { + recordUiEntered = false + delay(16) + recordUiEntered = true + } else { + recordUiEntered = false + } + } + val recordUiAlpha by animateFloatAsState( + targetValue = if (recordUiEntered) 1f else 0f, + animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing), + label = "record_ui_alpha" + ) + val recordUiShift by animateDpAsState( + targetValue = if (recordUiEntered) 0.dp else 20.dp, + animationSpec = tween(durationMillis = 180, easing = FastOutLinearInEasing), + label = "record_ui_shift" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(recordingPanelColor) + .padding(start = 13.dp, end = 94.dp) // record panel paddings + ) { + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .graphicsLayer { + alpha = recordUiAlpha + translationX = with(density) { recordUiShift.toPx() } + }, + verticalAlignment = Alignment.CenterVertically + ) { + RecordBlinkDot(isDarkTheme = isDarkTheme) + Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot + Text( + text = formatVoiceRecordTimer(voiceElapsedMs), + color = recordingTextColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = "CANCEL", + color = PrimaryBlue, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + alpha = recordUiAlpha + translationX = with(density) { recordUiShift.toPx() } + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { stopVoiceRecording(send = false) } + ) + } + + Box( + modifier = Modifier + .requiredSize(104.dp) // do not affect input row height + .offset(x = 8.dp), + contentAlignment = Alignment.Center + ) { + VoiceButtonBlob( + voiceLevel = voiceLevel, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .size(82.dp) // Telegram RecordCircle radius 41dp + .shadow( + elevation = 10.dp, + shape = CircleShape, + clip = false + ) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { stopVoiceRecording(send = true) }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TelegramSendIcon, + contentDescription = "Send voice message", + tint = Color.White, + modifier = Modifier.size(30.dp) + ) + } + } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + IconButton( + onClick = onAttachClick, + modifier = Modifier.size(40.dp) + ) { + Icon( + painter = TelegramIcons.Attach, + contentDescription = "Attach", + tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) + else Color(0xFF8E8E93).copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 40.dp, max = 150.dp) + .background(color = backgroundColor) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.TopStart + ) { + AppleEmojiTextField( + value = value, + onValueChange = { newValue -> onValueChange(newValue) }, + textColor = textColor, + textSize = 16f, + hint = "Type message...", + hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), + modifier = Modifier.fillMaxWidth(), + requestFocus = hasReply, + onViewCreated = { view -> editTextView = view }, + onFocusChanged = { hasFocus -> + if (hasFocus && showEmojiPicker) { + onToggleEmojiPicker(false) + } + }, + onSelectionChanged = { start, end -> + selectionStart = start + selectionEnd = end + } + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + IconButton( + onClick = { toggleEmojiPicker() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + painter = if (showEmojiPicker) TelegramIcons.Keyboard + else TelegramIcons.Smile, + contentDescription = "Emoji", + tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) + else Color(0xFF8E8E93).copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + + AnimatedVisibility( + visible = canSend || isSending, + enter = scaleIn(tween(150)) + fadeIn(tween(150)), + exit = scaleOut(tween(100)) + fadeOut(tween(100)) + ) { + IconButton( + onClick = { handleSend() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = TelegramSendIcon, + contentDescription = "Send", + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + } + } + + AnimatedVisibility( + visible = !canSend && !isSending, + enter = scaleIn(tween(140)) + fadeIn(tween(140)), + exit = scaleOut(tween(100)) + fadeOut(tween(100)) + ) { + IconButton( + onClick = { requestVoiceRecording() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Record voice message", + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + } } - ) - } - - Spacer(modifier = Modifier.width(6.dp)) - - IconButton( - onClick = { toggleEmojiPicker() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - painter = if (showEmojiPicker) TelegramIcons.Keyboard - else TelegramIcons.Smile, - contentDescription = "Emoji", - tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) - else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - - AnimatedVisibility( - visible = canSend || isSending, - enter = scaleIn(tween(150)) + fadeIn(tween(150)), - exit = scaleOut(tween(100)) + fadeOut(tween(100)) - ) { - IconButton( - onClick = { handleSend() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = TelegramSendIcon, - contentDescription = "Send", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) } - } } } } From fad8bfb1d13eeff81e24adb669fc404e657741c8 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 20:36:51 +0500 Subject: [PATCH 03/32] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20PAUSED=20=D0=B8=20=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20pause/resume=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../messenger/network/AttachmentType.kt | 1 + .../messenger/ui/chats/ChatDetailScreen.kt | 44 +- .../messenger/ui/chats/ChatViewModel.kt | 349 ++++++++++- .../messenger/ui/chats/ChatsListViewModel.kt | 2 + .../chats/components/AttachmentComponents.kt | 429 +++++++++++++- .../chats/components/ChatDetailComponents.kt | 20 +- .../ui/chats/input/ChatDetailInput.kt | 549 ++++++++++++++++-- 7 files changed, 1320 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt index 7500650..e194ceb 100644 --- a/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt +++ b/app/src/main/java/com/rosetta/messenger/network/AttachmentType.kt @@ -10,6 +10,7 @@ enum class AttachmentType(val value: Int) { AVATAR(3), // Аватар пользователя CALL(4), // Событие звонка (пропущен/принят/завершен) VOICE(5), // Голосовое сообщение + VIDEO_CIRCLE(6), // Видео-кружок (video note) UNKNOWN(-1); // Неизвестный тип companion object { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index fc7c39c..641f636 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -3705,16 +3705,32 @@ fun ChatDetailScreen( onMediaSelected = { selectedMedia, caption -> val imageUris = selectedMedia.filter { !it.isVideo }.map { it.uri } - if (imageUris.isNotEmpty()) { + val videoUris = + selectedMedia.filter { it.isVideo }.map { it.uri } + if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) { showMediaPicker = false inputFocusTrigger++ - viewModel.sendImageGroupFromUris(imageUris, caption) + if (imageUris.isNotEmpty()) { + viewModel.sendImageGroupFromUris( + imageUris, + caption + ) + } + if (videoUris.isNotEmpty()) { + videoUris.forEach { uri -> + viewModel.sendVideoCircleFromUri(uri) + } + } } }, onMediaSelectedWithCaption = { mediaItem, caption -> showMediaPicker = false inputFocusTrigger++ - viewModel.sendImageFromUri(mediaItem.uri, caption) + if (mediaItem.isVideo) { + viewModel.sendVideoCircleFromUri(mediaItem.uri) + } else { + viewModel.sendImageFromUri(mediaItem.uri, caption) + } }, onOpenCamera = { val imm = @@ -3806,16 +3822,32 @@ fun ChatDetailScreen( onMediaSelected = { selectedMedia, caption -> val imageUris = selectedMedia.filter { !it.isVideo }.map { it.uri } - if (imageUris.isNotEmpty()) { + val videoUris = + selectedMedia.filter { it.isVideo }.map { it.uri } + if (imageUris.isNotEmpty() || videoUris.isNotEmpty()) { showMediaPicker = false inputFocusTrigger++ - viewModel.sendImageGroupFromUris(imageUris, caption) + if (imageUris.isNotEmpty()) { + viewModel.sendImageGroupFromUris( + imageUris, + caption + ) + } + if (videoUris.isNotEmpty()) { + videoUris.forEach { uri -> + viewModel.sendVideoCircleFromUri(uri) + } + } } }, onMediaSelectedWithCaption = { mediaItem, caption -> showMediaPicker = false inputFocusTrigger++ - viewModel.sendImageFromUri(mediaItem.uri, caption) + if (mediaItem.isVideo) { + viewModel.sendVideoCircleFromUri(mediaItem.uri) + } else { + viewModel.sendImageFromUri(mediaItem.uri, caption) + } }, onOpenCamera = { val imm = 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 d8dfe1e..0483559 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 @@ -3,7 +3,9 @@ package com.rosetta.messenger.ui.chats import android.app.Application import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever import android.util.Base64 +import android.webkit.MimeTypeMap import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager @@ -656,7 +658,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { when (parseAttachmentType(attachment)) { AttachmentType.IMAGE, AttachmentType.FILE, - AttachmentType.AVATAR -> { + AttachmentType.AVATAR, + AttachmentType.VIDEO_CIRCLE -> { hasMediaAttachment = true if (attachment.optString("localUri", "").isNotBlank()) { // Локальный URI ещё есть => загрузка/подготовка не завершена. @@ -1626,6 +1629,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { "avatar" -> AttachmentType.AVATAR.value "call" -> AttachmentType.CALL.value "voice" -> AttachmentType.VOICE.value + "video_circle", "videocircle", "video_note", "videonote", "round_video", "videoround", "video" -> + AttachmentType.VIDEO_CIRCLE.value else -> -1 } } @@ -1796,7 +1801,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой if ((effectiveType == AttachmentType.IMAGE || effectiveType == AttachmentType.AVATAR || - effectiveType == AttachmentType.VOICE) && + effectiveType == AttachmentType.VOICE || + effectiveType == AttachmentType.VIDEO_CIRCLE) && blob.isEmpty() && attachmentId.isNotEmpty() ) { @@ -2569,6 +2575,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar" message.attachments.any { it.type == AttachmentType.CALL } -> "Call" message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message" + message.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video message" message.forwardedMessages.isNotEmpty() -> "Forwarded message" message.replyData != null -> "Reply" else -> "Pinned message" @@ -4809,6 +4816,344 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + private data class VideoCircleMeta( + val durationSec: Int, + val width: Int, + val height: Int, + val mimeType: String + ) + + private fun bytesToHex(bytes: ByteArray): String { + val hexChars = "0123456789abcdef".toCharArray() + val output = CharArray(bytes.size * 2) + var index = 0 + bytes.forEach { byte -> + val value = byte.toInt() and 0xFF + output[index++] = hexChars[value ushr 4] + output[index++] = hexChars[value and 0x0F] + } + return String(output) + } + + private fun resolveVideoCircleMeta( + application: Application, + videoUri: android.net.Uri + ): VideoCircleMeta { + var durationSec = 1 + var width = 0 + var height = 0 + + val mimeType = + application.contentResolver.getType(videoUri)?.trim().orEmpty().ifBlank { + val ext = + MimeTypeMap.getFileExtensionFromUrl(videoUri.toString()) + ?.lowercase(Locale.ROOT) + ?: "" + MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4" + } + + runCatching { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(application, videoUri) + val durationMs = + retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION + ) + ?.toLongOrNull() + ?: 0L + val rawWidth = + retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH + ) + ?.toIntOrNull() + ?: 0 + val rawHeight = + retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT + ) + ?.toIntOrNull() + ?: 0 + val rotation = + retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION + ) + ?.toIntOrNull() + ?: 0 + retriever.release() + + durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1) + val rotated = rotation == 90 || rotation == 270 + width = if (rotated) rawHeight else rawWidth + height = if (rotated) rawWidth else rawHeight + } + + return VideoCircleMeta( + durationSec = durationSec, + width = width.coerceAtLeast(0), + height = height.coerceAtLeast(0), + mimeType = mimeType + ) + } + + private suspend fun encodeVideoUriToHex( + application: Application, + videoUri: android.net.Uri + ): String? { + return withContext(Dispatchers.IO) { + runCatching { + application.contentResolver.openInputStream(videoUri)?.use { stream -> + val bytes = stream.readBytes() + if (bytes.isEmpty()) null else bytesToHex(bytes) + } + }.getOrNull() + } + } + + /** + * 🎥 Отправка видео-кружка (video note) из URI. + * Использует такой же transport + шифрование пайплайн, как voice attachment. + */ + fun sendVideoCircleFromUri(videoUri: android.net.Uri) { + val recipient = opponentKey + val sender = myPublicKey + val privateKey = myPrivateKey + val context = getApplication() + + if (recipient == null || sender == null || privateKey == null) { + return + } + if (isSending) { + return + } + + val fileSize = runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(context, videoUri) } + .getOrDefault(0L) + val maxBytes = com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L + if (fileSize > 0L && fileSize > maxBytes) { + return + } + + isSending = true + + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) + val timestamp = System.currentTimeMillis() + val attachmentId = "video_circle_$timestamp" + val meta = resolveVideoCircleMeta(context, videoUri) + val preview = "${meta.durationSec}::${meta.mimeType}" + + val optimisticMessage = + ChatMessage( + id = messageId, + text = "", + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = + listOf( + MessageAttachment( + id = attachmentId, + blob = "", + type = AttachmentType.VIDEO_CIRCLE, + preview = preview, + width = meta.width, + height = meta.height, + localUri = videoUri.toString() + ) + ) + ) + addMessageSafely(optimisticMessage) + _inputText.value = "" + + backgroundUploadScope.launch { + try { + val optimisticAttachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.VIDEO_CIRCLE.value) + put("preview", preview) + put("blob", "") + put("width", meta.width) + put("height", meta.height) + put("localUri", videoUri.toString()) + } + ) + } + .toString() + + saveMessageToDatabase( + messageId = messageId, + text = "", + encryptedContent = "", + encryptedKey = "", + timestamp = timestamp, + isFromMe = true, + delivered = 0, + attachmentsJson = optimisticAttachmentsJson, + opponentPublicKey = recipient + ) + saveDialog("Video message", timestamp, opponentPublicKey = recipient) + } catch (_: Exception) { + } + + try { + val videoHex = encodeVideoUriToHex(context, videoUri) + if (videoHex.isNullOrBlank()) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + return@launch + } + sendVideoCircleMessageInternal( + messageId = messageId, + attachmentId = attachmentId, + timestamp = timestamp, + videoHex = videoHex, + preview = preview, + width = meta.width, + height = meta.height, + recipient = recipient, + sender = sender, + privateKey = privateKey + ) + } catch (_: Exception) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) + } finally { + isSending = false + } + } + } + + private suspend fun sendVideoCircleMessageInternal( + messageId: String, + attachmentId: String, + timestamp: Long, + videoHex: String, + preview: String, + width: Int, + height: Int, + recipient: String, + sender: String, + privateKey: String + ) { + var packetSentToProtocol = false + try { + val application = getApplication() + + val encryptionContext = + buildEncryptionContext( + plaintext = "", + recipient = recipient, + privateKey = privateKey + ) ?: throw IllegalStateException("Cannot resolve chat encryption context") + val encryptedContent = encryptionContext.encryptedContent + val encryptedKey = encryptionContext.encryptedKey + val aesChachaKey = encryptionContext.aesChachaKey + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + + val encryptedVideoBlob = encryptAttachmentPayload(videoHex, encryptionContext) + + val isSavedMessages = (sender == recipient) + val uploadTag = + if (!isSavedMessages) { + TransportManager.uploadFile(attachmentId, encryptedVideoBlob) + } else { + "" + } + val attachmentTransportServer = + if (uploadTag.isNotEmpty()) { + TransportManager.getTransportServer().orEmpty() + } else { + "" + } + + val videoAttachment = + MessageAttachment( + id = attachmentId, + blob = "", + type = AttachmentType.VIDEO_CIRCLE, + preview = preview, + width = width, + height = height, + transportTag = uploadTag, + transportServer = attachmentTransportServer + ) + + val packet = + PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.aesChachaKey = aesChachaKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(videoAttachment) + } + + if (!isSavedMessages) { + ProtocolManager.send(packet) + packetSentToProtocol = true + } + + runCatching { + AttachmentFileManager.saveAttachment( + context = application, + blob = videoHex, + attachmentId = attachmentId, + publicKey = sender, + privateKey = privateKey + ) + } + + val attachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.VIDEO_CIRCLE.value) + put("preview", preview) + put("blob", "") + put("width", width) + put("height", height) + put("transportTag", uploadTag) + put("transportServer", attachmentTransportServer) + } + ) + } + .toString() + + updateMessageStatusAndAttachmentsInDb( + messageId = messageId, + delivered = if (isSavedMessages) 1 else 0, + attachmentsJson = attachmentsJson + ) + + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + updateMessageAttachments(messageId, null) + } + saveDialog("Video message", timestamp, opponentPublicKey = recipient) + } catch (_: Exception) { + if (packetSentToProtocol) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + } else { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + } + } + } + /** * 🎙️ Отправка голосового сообщения. * blob хранится как HEX строка opus/webm байт (desktop parity). diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index a06d219..43b5e74 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -584,6 +584,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio 3 -> "Avatar" // AttachmentType.AVATAR = 3 4 -> "Call" // AttachmentType.CALL = 4 5 -> "Voice message" // AttachmentType.VOICE = 5 + 6 -> "Video message" // AttachmentType.VIDEO_CIRCLE = 6 else -> if (inferredCall) "Call" else null } } @@ -607,6 +608,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio "avatar" -> 3 "call" -> 4 "voice" -> 5 + "video_circle", "videocircle", "video_note", "videonote", "round_video", "videoround", "video" -> 6 else -> -1 } } 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 720f5f0..55488ce 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 @@ -6,10 +6,12 @@ import android.graphics.BitmapFactory import android.graphics.Matrix import android.media.AudioAttributes import android.media.MediaPlayer +import android.net.Uri import android.os.SystemClock import android.util.Base64 import android.util.LruCache import android.webkit.MimeTypeMap +import android.widget.VideoView import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -58,6 +60,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.exifinterface.media.ExifInterface import com.rosetta.messenger.R import com.rosetta.messenger.crypto.CryptoManager @@ -686,6 +689,19 @@ fun MessageAttachments( messageStatus = messageStatus ) } + AttachmentType.VIDEO_CIRCLE -> { + VideoCircleAttachment( + attachment = attachment, + chachaKey = chachaKey, + chachaKeyPlainHex = chachaKeyPlainHex, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + timestamp = timestamp, + messageStatus = messageStatus + ) + } else -> { // Desktop parity: unsupported/legacy attachment gets explicit compatibility card. LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme) @@ -1804,10 +1820,54 @@ private fun parseVoicePreview(preview: String): Pair> { wavesPart.split(",") .mapNotNull { it.trim().toFloatOrNull() } .map { it.coerceIn(0f, 1f) } - } + } return duration to waves } +private data class VideoCirclePreviewMeta( + val durationSec: Int, + val mimeType: String +) + +private fun parseVideoCirclePreview(preview: String): VideoCirclePreviewMeta { + if (preview.isBlank()) { + return VideoCirclePreviewMeta(durationSec = 1, mimeType = "video/mp4") + } + val durationPart = preview.substringBefore("::", preview).trim() + val mimePart = preview.substringAfter("::", "").trim() + val duration = durationPart.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val mime = + if (mimePart.contains("/")) { + mimePart + } else { + "video/mp4" + } + return VideoCirclePreviewMeta(durationSec = duration, mimeType = mime) +} + +private fun decodeVideoCirclePayload(data: String): ByteArray? { + return decodeHexPayload(data) ?: decodeBase64Payload(data) +} + +private fun ensureVideoCirclePlaybackUri( + context: android.content.Context, + attachmentId: String, + payload: String, + mimeType: String, + localUri: String = "" +): Uri? { + if (localUri.isNotBlank()) { + return runCatching { Uri.parse(localUri) }.getOrNull() + } + val bytes = decodeVideoCirclePayload(payload) ?: return null + val extension = + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.ifBlank { null } ?: "mp4" + val directory = File(context.cacheDir, "video_circles").apply { mkdirs() } + val file = File(directory, "$attachmentId.$extension") + runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null + return Uri.fromFile(file) +} + private fun normalizeVoiceWaves(source: List, targetLength: Int): List { if (targetLength <= 0) return emptyList() if (source.isEmpty()) return List(targetLength) { 0f } @@ -2320,6 +2380,373 @@ private fun VoiceAttachment( } } +@Composable +private fun VideoCircleAttachment( + attachment: MessageAttachment, + chachaKey: String, + chachaKeyPlainHex: String, + privateKey: String, + senderPublicKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + timestamp: java.util.Date, + messageStatus: MessageStatus = MessageStatus.READ +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val previewMeta = remember(attachment.preview) { parseVideoCirclePreview(getPreview(attachment)) } + val fallbackDurationMs = previewMeta.durationSec.coerceAtLeast(1) * 1000 + + var payload by + remember(attachment.id, attachment.blob) { + mutableStateOf(attachment.blob.trim()) + } + var downloadStatus by + remember(attachment.id, attachment.blob, attachment.transportTag, attachment.localUri) { + mutableStateOf( + when { + attachment.localUri.isNotBlank() -> DownloadStatus.DOWNLOADED + attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED + attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED + else -> DownloadStatus.ERROR + } + ) + } + var errorText by remember { mutableStateOf("") } + var playbackUri by remember(attachment.id, attachment.localUri) { + mutableStateOf( + runCatching { + if (attachment.localUri.isNotBlank()) Uri.parse(attachment.localUri) else null + }.getOrNull() + ) + } + var boundUri by remember(attachment.id) { mutableStateOf(null) } + var isPrepared by remember(attachment.id) { mutableStateOf(false) } + var isPlaying by remember(attachment.id) { mutableStateOf(false) } + var playbackPositionMs by remember(attachment.id) { mutableIntStateOf(0) } + var playbackDurationMs by remember(attachment.id) { mutableIntStateOf(fallbackDurationMs) } + var videoViewRef by remember(attachment.id) { mutableStateOf(null) } + + LaunchedEffect(payload, attachment.localUri, attachment.id, previewMeta.mimeType) { + if (playbackUri != null) return@LaunchedEffect + if (attachment.localUri.isNotBlank()) { + playbackUri = runCatching { Uri.parse(attachment.localUri) }.getOrNull() + return@LaunchedEffect + } + if (payload.isBlank()) return@LaunchedEffect + val prepared = + ensureVideoCirclePlaybackUri( + context = context, + attachmentId = attachment.id, + payload = payload, + mimeType = previewMeta.mimeType + ) + if (prepared != null) { + playbackUri = prepared + if (downloadStatus != DownloadStatus.DOWNLOADING && + downloadStatus != DownloadStatus.DECRYPTING + ) { + downloadStatus = DownloadStatus.DOWNLOADED + } + if (errorText.isNotBlank()) errorText = "" + } else { + downloadStatus = DownloadStatus.ERROR + if (errorText.isBlank()) errorText = "Cannot decode video" + } + } + + LaunchedEffect(isPlaying, videoViewRef) { + val player = videoViewRef ?: return@LaunchedEffect + while (isPlaying) { + playbackPositionMs = runCatching { player.currentPosition }.getOrDefault(0).coerceAtLeast(0) + delay(120) + } + } + + DisposableEffect(attachment.id) { + onDispose { + runCatching { + videoViewRef?.stopPlayback() + } + videoViewRef = null + isPlaying = false + isPrepared = false + boundUri = null + } + } + + val triggerDownload: () -> Unit = download@{ + if (attachment.transportTag.isBlank()) { + downloadStatus = DownloadStatus.ERROR + errorText = "Video is not available" + return@download + } + scope.launch { + downloadStatus = DownloadStatus.DOWNLOADING + errorText = "" + val decrypted = + downloadAndDecryptVoicePayload( + attachmentId = attachment.id, + downloadTag = attachment.transportTag, + chachaKey = chachaKey, + privateKey = privateKey, + transportServer = attachment.transportServer, + chachaKeyPlainHex = chachaKeyPlainHex + ) + if (decrypted.isNullOrBlank()) { + downloadStatus = DownloadStatus.ERROR + errorText = "Failed to decrypt" + return@launch + } + val saved = + runCatching { + AttachmentFileManager.saveAttachment( + context = context, + blob = decrypted, + attachmentId = attachment.id, + publicKey = senderPublicKey, + privateKey = privateKey + ) + } + .getOrDefault(false) + payload = decrypted + playbackUri = + ensureVideoCirclePlaybackUri( + context = context, + attachmentId = attachment.id, + payload = decrypted, + mimeType = previewMeta.mimeType + ) + if (!saved) { + runCatching { android.util.Log.w(TAG, "Video circle cache save failed: ${attachment.id}") } + } + if (playbackUri == null) { + downloadStatus = DownloadStatus.ERROR + errorText = "Cannot decode video" + } else { + downloadStatus = DownloadStatus.DOWNLOADED + } + } + } + + val onMainAction: () -> Unit = { + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload() + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit + DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> { + if (playbackUri == null) { + triggerDownload() + } else { + isPlaying = !isPlaying + } + } + } + } + + val durationToShowSec = + if (isPrepared && playbackDurationMs > 0) { + (playbackDurationMs / 1000).coerceAtLeast(1) + } else { + previewMeta.durationSec.coerceAtLeast(1) + } + val secondaryTextColor = + if (isOutgoing) Color.White.copy(alpha = 0.82f) + else if (isDarkTheme) Color(0xFFCCD3E0) + else Color(0xFF5F6D82) + + Box( + modifier = + Modifier.padding(vertical = 4.dp) + .size(220.dp) + .clip(CircleShape) + .background( + if (isOutgoing) { + Color(0xFF3A9DFB) + } else if (isDarkTheme) { + Color(0xFF22252B) + } else { + Color(0xFFE8EEF7) + } + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onMainAction() } + ) { + val uri = playbackUri + if (uri != null && (downloadStatus == DownloadStatus.DOWNLOADED || downloadStatus == DownloadStatus.PENDING)) { + AndroidView( + factory = { ctx -> VideoView(ctx) }, + modifier = Modifier.fillMaxSize(), + update = { videoView -> + videoViewRef = videoView + val targetUri = uri.toString() + if (boundUri != targetUri) { + boundUri = targetUri + isPrepared = false + playbackPositionMs = 0 + runCatching { + videoView.setVideoURI(uri) + videoView.setOnPreparedListener { mediaPlayer -> + mediaPlayer.isLooping = false + playbackDurationMs = + mediaPlayer.duration + .coerceAtLeast(fallbackDurationMs) + isPrepared = true + if (isPlaying) { + runCatching { videoView.start() } + } + } + videoView.setOnCompletionListener { + isPlaying = false + playbackPositionMs = playbackDurationMs + } + } + } + if (isPrepared) { + if (isPlaying && !videoView.isPlaying) { + runCatching { videoView.start() } + } else if (!isPlaying && videoView.isPlaying) { + runCatching { videoView.pause() } + } + } + } + ) + } else { + Box( + modifier = + Modifier.fillMaxSize() + .background( + Brush.radialGradient( + colors = + if (isOutgoing) { + listOf( + Color(0x6637A7FF), + Color(0x3337A7FF), + Color(0x0037A7FF) + ) + } else if (isDarkTheme) { + listOf( + Color(0x553A4150), + Color(0x33262C39), + Color(0x00262C39) + ) + } else { + listOf( + Color(0x5593B4E8), + Color(0x338AB0E5), + Color(0x008AB0E5) + ) + } + ) + ) + ) + } + + Box( + modifier = + Modifier.align(Alignment.Center) + .size(52.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.38f)), + contentAlignment = Alignment.Center + ) { + when (downloadStatus) { + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + CircularProgressIndicator( + modifier = Modifier.size(26.dp), + color = Color.White, + strokeWidth = 2.2.dp + ) + } + DownloadStatus.NOT_DOWNLOADED -> { + Icon( + painter = painterResource(R.drawable.msg_download), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + DownloadStatus.ERROR -> { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color(0xFFFF8A8A), + modifier = Modifier.size(24.dp) + ) + } + else -> { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + } + + Row( + modifier = + Modifier.align(Alignment.BottomEnd) + .padding(end = 10.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = formatVoiceDuration(durationToShowSec), + fontSize = 11.sp, + color = secondaryTextColor + ) + if (isOutgoing) { + when (messageStatus) { + MessageStatus.SENDING -> { + Icon( + painter = TelegramIcons.Clock, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + } + MessageStatus.SENT, + MessageStatus.DELIVERED -> { + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + } + MessageStatus.READ -> { + Box(modifier = Modifier.height(14.dp)) { + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp) + ) + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(14.dp).offset(x = 4.dp) + ) + } + } + MessageStatus.ERROR -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = Color(0xFFE55757), + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } +} + /** File attachment - Telegram style */ @Composable fun FileAttachment( 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 56310ee..f3f6100 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 @@ -684,7 +684,18 @@ fun MessageBubble( message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType - .IMAGE + .IMAGE || + it.type == + com.rosetta.messenger.network + .AttachmentType + .VIDEO_CIRCLE + } + val hasOnlyVideoCircle = + hasOnlyMedia && + message.attachments.all { + it.type == + com.rosetta.messenger.network.AttachmentType + .VIDEO_CIRCLE } // Фото + caption (как в Telegram) @@ -725,7 +736,8 @@ fun MessageBubble( hasImageWithCaption -> PaddingValues(0.dp) else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) } - val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp + val bubbleBorderWidth = + if (hasOnlyMedia && !hasOnlyVideoCircle) 1.dp else 0.dp // Telegram-style: ширина пузырька = ширина фото // Caption переносится на новые строки, не расширяя пузырёк @@ -743,7 +755,9 @@ fun MessageBubble( // Вычисляем ширину фото для ограничения пузырька val photoWidth = if (hasImageWithCaption || hasOnlyMedia) { - if (isImageCollage) { + if (hasOnlyVideoCircle) { + 220.dp + } else if (isImageCollage) { maxCollageWidth } else { val firstImage = 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 96ed95e..a9e64c9 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 @@ -11,12 +11,15 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Videocam import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -28,6 +31,7 @@ import androidx.compose.material3.* import androidx.compose.ui.draw.alpha import com.rosetta.messenger.ui.icons.TelegramIcons import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,9 +40,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +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.LocalFocusManager @@ -73,6 +80,7 @@ import com.rosetta.messenger.ui.chats.ChatViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.coroutines.Job import java.io.File import java.util.Locale import java.util.UUID @@ -142,6 +150,19 @@ private fun formatVoiceRecordTimer(elapsedMs: Long): String { return "$minutes:$seconds,$tenths" } +private enum class RecordMode { + VOICE, + VIDEO +} + +private enum class RecordUiState { + IDLE, + PRESSING, + RECORDING, + LOCKED, + PAUSED +} + @Composable private fun RecordBlinkDot( isDarkTheme: Boolean, @@ -544,15 +565,93 @@ fun MessageInputBar( var voiceRecorder by remember { mutableStateOf(null) } var voiceOutputFile by remember { mutableStateOf(null) } var isVoiceRecording by remember { mutableStateOf(false) } + var isVoiceRecordTransitioning 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 slideDx by remember { mutableFloatStateOf(0f) } + var slideDy by remember { mutableFloatStateOf(0f) } + var pendingLongPressJob by remember { mutableStateOf(null) } + var pendingRecordAfterPermission by remember { mutableStateOf(false) } var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } var voiceElapsedMs by remember { mutableLongStateOf(0L) } var voiceWaves by remember { mutableStateOf>(emptyList()) } + var isVoicePaused by remember { mutableStateOf(false) } + var voicePausedElapsedMs by remember { mutableLongStateOf(0L) } + var inputPanelHeightPx by remember { mutableIntStateOf(0) } + var inputPanelY by remember { mutableFloatStateOf(0f) } + var normalInputRowHeightPx by remember { mutableIntStateOf(0) } + 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) {} + } + + fun inputHeightsSnapshot(): String { + val panelDp = with(density) { inputPanelHeightPx.toDp().value.toInt() } + val normalDp = with(density) { normalInputRowHeightPx.toDp().value.toInt() } + val recDp = with(density) { recordingInputRowHeightPx.toDp().value.toInt() } + return "panel=${inputPanelHeightPx}px(${panelDp}dp) normal=${normalInputRowHeightPx}px(${normalDp}dp) rec=${recordingInputRowHeightPx}px(${recDp}dp)" + } + + fun setRecordUiState(newState: RecordUiState, reason: String) { + if (recordUiState == newState) return + val oldState = recordUiState + recordUiState = newState + inputJumpLog("recordState $oldState -> $newState reason=$reason mode=$recordMode") + } + + fun resetGestureState() { + slideDx = 0f + slideDy = 0f + pressStartX = 0f + pressStartY = 0f + pendingLongPressJob?.cancel() + pendingLongPressJob = null + } + + fun toggleRecordModeByTap() { + recordMode = if (recordMode == RecordMode.VOICE) RecordMode.VIDEO else RecordMode.VOICE + inputJumpLog("recordMode toggled -> $recordMode (short tap)") + } + + val shouldPinBottomForInput = + isKeyboardVisible || + coordinator.isEmojiBoxVisible || + isVoiceRecordTransitioning || + recordUiState == RecordUiState.PRESSING || + recordUiState == RecordUiState.PAUSED + val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput fun stopVoiceRecording(send: Boolean) { + isVoiceRecordTransitioning = false + inputJumpLog( + "stopVoiceRecording begin send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + + "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx" + ) val recorder = voiceRecorder val outputFile = voiceOutputFile val elapsedSnapshot = - if (voiceRecordStartedAtMs > 0L) { + if (isVoicePaused && voicePausedElapsedMs > 0L) { + voicePausedElapsedMs + } else if (voiceRecordStartedAtMs > 0L) { maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs) } else { voiceElapsedMs @@ -563,6 +662,8 @@ fun MessageInputBar( voiceRecorder = null voiceOutputFile = null isVoiceRecording = false + isVoicePaused = false + voicePausedElapsedMs = 0L voiceRecordStartedAtMs = 0L voiceElapsedMs = 0L voiceWaves = emptyList() @@ -589,10 +690,21 @@ fun MessageInputBar( } } runCatching { outputFile?.delete() } + resetGestureState() + setRecordUiState(RecordUiState.IDLE, "stop(send=$send)") + inputJumpLog( + "stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + + "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx" + ) } fun startVoiceRecording() { if (isVoiceRecording) return + inputJumpLog( + "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + + "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" + ) try { val voiceDir = File(context.cacheDir, "voice_recordings").apply { mkdirs() } @@ -619,12 +731,39 @@ fun MessageInputBar( voiceRecordStartedAtMs = System.currentTimeMillis() voiceElapsedMs = 0L voiceWaves = emptyList() - isVoiceRecording = true + isVoiceRecordTransitioning = true val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus(force = true) + if (showEmojiPicker || coordinator.isEmojiBoxVisible) { + onToggleEmojiPicker(false) + } + inputJumpLog( + "startVoiceRecording armed mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + + "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + + "pinBottom=$shouldPinBottomForInput " + + "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx" + ) + + scope.launch { + repeat(12) { + if (!isKeyboardVisible && !coordinator.isEmojiBoxVisible) return@repeat + delay(16) + } + isVoiceRecording = true + isVoiceRecordTransitioning = false + if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) { + setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started") + } + inputJumpLog( + "startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + + "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + + "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx" + ) + } } catch (_: Exception) { + isVoiceRecordTransitioning = false stopVoiceRecording(send = false) android.widget.Toast.makeText( context, @@ -634,13 +773,74 @@ fun MessageInputBar( } } + fun pauseVoiceRecording() { + val recorder = voiceRecorder ?: return + if (!isVoiceRecording || isVoicePaused) return + 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}") + } + } + + fun resumeVoiceRecording() { + val recorder = voiceRecorder ?: return + if (!isVoiceRecording || !isVoicePaused) return + inputJumpLog("resumeVoiceRecording mode=$recordMode state=$recordUiState") + try { + recorder.resume() + voiceRecordStartedAtMs = System.currentTimeMillis() - voicePausedElapsedMs + isVoicePaused = false + voicePausedElapsedMs = 0L + setRecordUiState(RecordUiState.LOCKED, "resume-pressed") + } catch (e: Exception) { + 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.PRESSING || + 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) + } + } + val recordAudioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { - startVoiceRecording() + if (pendingRecordAfterPermission) { + pendingRecordAfterPermission = false + setRecordUiState(RecordUiState.RECORDING, "audio-permission-granted") + startVoiceRecording() + } } else { + pendingRecordAfterPermission = false + setRecordUiState(RecordUiState.IDLE, "audio-permission-denied") android.widget.Toast.makeText( context, "Microphone permission is required for voice messages", @@ -649,7 +849,11 @@ fun MessageInputBar( } } - fun requestVoiceRecording() { + fun requestVoiceRecordingFromHold(): Boolean { + inputJumpLog( + "requestVoiceRecordingFromHold mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + + "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" + ) val granted = ContextCompat.checkSelfPermission( context, @@ -657,14 +861,36 @@ fun MessageInputBar( ) == PackageManager.PERMISSION_GRANTED if (granted) { startVoiceRecording() + return true } else { + pendingRecordAfterPermission = true recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + return true } } - LaunchedEffect(isVoiceRecording, voiceRecorder) { - if (!isVoiceRecording) return@LaunchedEffect - while (isVoiceRecording && voiceRecorder != null) { + val holdToRecordDelayMs = 260L + val cancelDragThresholdPx = with(density) { 92.dp.toPx() } + val lockDragThresholdPx = with(density) { 70.dp.toPx() } + + fun tryStartRecordingForCurrentMode(): Boolean { + return if (recordMode == RecordMode.VOICE) { + setRecordUiState(RecordUiState.RECORDING, "hold-threshold-passed") + requestVoiceRecordingFromHold() + } else { + setRecordUiState(RecordUiState.IDLE, "video-mode-record-not-ready") + android.widget.Toast.makeText( + context, + "Video circles recording will be enabled in next step", + android.widget.Toast.LENGTH_SHORT + ).show() + false + } + } + + LaunchedEffect(isVoiceRecording, voiceRecorder, isVoicePaused) { + if (!isVoiceRecording || isVoicePaused) return@LaunchedEffect + while (isVoiceRecording && voiceRecorder != null && !isVoicePaused) { if (voiceRecordStartedAtMs > 0L) { voiceElapsedMs = (System.currentTimeMillis() - voiceRecordStartedAtMs).coerceAtLeast(0L) @@ -673,9 +899,9 @@ fun MessageInputBar( } } - LaunchedEffect(isVoiceRecording, voiceRecorder) { - if (!isVoiceRecording) return@LaunchedEffect - while (isVoiceRecording && voiceRecorder != null) { + LaunchedEffect(isVoiceRecording, voiceRecorder, isVoicePaused) { + if (!isVoiceRecording || isVoicePaused) return@LaunchedEffect + while (isVoiceRecording && voiceRecorder != null && !isVoicePaused) { val amplitude = runCatching { voiceRecorder?.maxAmplitude ?: 0 }.getOrDefault(0) val normalized = (amplitude.toFloat() / 32_767f).coerceIn(0f, 1f) voiceWaves = (voiceWaves + normalized).takeLast(120) @@ -685,8 +911,12 @@ fun MessageInputBar( DisposableEffect(Unit) { onDispose { + pendingRecordAfterPermission = false + resetGestureState() if (isVoiceRecording) { stopVoiceRecording(send = false) + } else { + setRecordUiState(RecordUiState.IDLE, "dispose") } } } @@ -925,19 +1155,18 @@ fun MessageInputBar( ) ) - val shouldAddNavBarPadding = - hasNativeNavigationBar && - !isKeyboardVisible && - !coordinator.isEmojiBoxVisible - Column( modifier = Modifier .fillMaxWidth() .background(color = backgroundColor) .padding( - bottom = if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 0.dp else 16.dp + bottom = if (shouldPinBottomForInput) 0.dp else 16.dp ) .then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier) + .onGloballyPositioned { coordinates -> + inputPanelHeightPx = coordinates.size.height + inputPanelY = coordinates.positionInWindow().y + } ) { AnimatedVisibility( visible = mentionSuggestions.isNotEmpty(), @@ -1354,13 +1583,17 @@ fun MessageInputBar( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 8.dp) + .onGloballyPositioned { coordinates -> + recordingInputRowHeightPx = coordinates.size.height + recordingInputRowY = coordinates.positionInWindow().y + }, contentAlignment = Alignment.CenterEnd ) { Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 40.dp) + .height(40.dp) .clip(RoundedCornerShape(20.dp)) .background(recordingPanelColor) .padding(start = 13.dp, end = 94.dp) // record panel paddings @@ -1385,57 +1618,111 @@ fun MessageInputBar( } Text( - text = "CANCEL", - color = PrimaryBlue, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier - .align(Alignment.Center) - .graphicsLayer { - alpha = recordUiAlpha - translationX = with(density) { recordUiShift.toPx() } - } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { stopVoiceRecording(send = false) } + text = + if (recordUiState == RecordUiState.LOCKED) { + "CANCEL" + } else { + "Slide left to cancel • up to lock" + }, + color = if (recordUiState == RecordUiState.LOCKED) PrimaryBlue else recordingTextColor.copy(alpha = 0.82f), + fontSize = if (recordUiState == RecordUiState.LOCKED) 15.sp else 13.sp, + fontWeight = if (recordUiState == RecordUiState.LOCKED) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .align(Alignment.Center) + .graphicsLayer { + alpha = recordUiAlpha + translationX = with(density) { recordUiShift.toPx() } + } + .then( + if (recordUiState == RecordUiState.LOCKED) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + inputJumpLog( + "tap CANCEL (locked) mode=$recordMode state=$recordUiState " + + "voice=$isVoiceRecording kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + + inputHeightsSnapshot() + ) + stopVoiceRecording(send = false) + } + } else { + Modifier + } + ) ) } Box( modifier = Modifier - .requiredSize(104.dp) // do not affect input row height + .size(40.dp) .offset(x = 8.dp), contentAlignment = Alignment.Center ) { VoiceButtonBlob( voiceLevel = voiceLevel, isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + // Visual-only enlargement like Telegram record circle, + // while keeping layout hitbox at normal input size. + scaleX = 2.05f + scaleY = 2.05f + clip = false + } ) - Box( - modifier = Modifier - .size(82.dp) // Telegram RecordCircle radius 41dp - .shadow( - elevation = 10.dp, - shape = CircleShape, - clip = false + if (recordUiState == RecordUiState.LOCKED) { + Box( + modifier = Modifier + .requiredSize(82.dp) + .shadow( + elevation = 10.dp, + shape = CircleShape, + clip = false + ) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + inputJumpLog( + "tap SEND (locked) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " + + "kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" + ) + stopVoiceRecording(send = true) + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TelegramSendIcon, + contentDescription = "Send voice message", + tint = Color.White, + modifier = Modifier.size(30.dp) ) - .clip(CircleShape) - .background(PrimaryBlue) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { stopVoiceRecording(send = true) }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = TelegramSendIcon, - contentDescription = "Send voice message", - tint = Color.White, - modifier = Modifier.size(30.dp) - ) + } + } else { + Box( + modifier = + Modifier + .requiredSize(82.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(30.dp) + ) + } } } } @@ -1444,7 +1731,11 @@ fun MessageInputBar( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 8.dp) + .onGloballyPositioned { coordinates -> + normalInputRowHeightPx = coordinates.size.height + normalInputRowY = coordinates.positionInWindow().y + }, verticalAlignment = Alignment.Bottom ) { IconButton( @@ -1481,6 +1772,12 @@ fun MessageInputBar( requestFocus = hasReply, onViewCreated = { view -> editTextView = view }, onFocusChanged = { hasFocus -> + if (hasFocus) { + inputJumpLog( + "tap INPUT focus=true voice=$isVoiceRecording kb=$isKeyboardVisible " + + "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" + ) + } if (hasFocus && showEmojiPicker) { onToggleEmojiPicker(false) } @@ -1533,13 +1830,141 @@ fun MessageInputBar( enter = scaleIn(tween(140)) + fadeIn(tween(140)), exit = scaleOut(tween(100)) + fadeOut(tween(100)) ) { - IconButton( - onClick = { requestVoiceRecording() }, - modifier = Modifier.size(40.dp) + Box( + modifier = + Modifier + .size(40.dp) + .pointerInput(Unit) { + awaitEachGesture { + if (canSend || isSending || isVoiceRecording || isVoiceRecordTransitioning) { + return@awaitEachGesture + } + + val down = awaitFirstDown(requireUnconsumed = false) + val tapSlopPx = viewConfiguration.touchSlop + var pointerIsDown = true + var maxAbsDx = 0f + var maxAbsDy = 0f + pressStartX = down.position.x + pressStartY = down.position.y + slideDx = 0f + slideDy = 0f + pendingRecordAfterPermission = false + setRecordUiState(RecordUiState.PRESSING, "mic-down") + inputJumpLog( + "mic DOWN mode=$recordMode state=$recordUiState " + + "voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}" + ) + + pendingLongPressJob?.cancel() + pendingLongPressJob = + scope.launch { + delay(holdToRecordDelayMs) + if (pointerIsDown && recordUiState == RecordUiState.PRESSING) { + val started = tryStartRecordingForCurrentMode() + if (!started) { + resetGestureState() + setRecordUiState(RecordUiState.IDLE, "hold-start-failed") + } + } + } + + 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") + } + } + RecordUiState.RECORDING -> { + inputJumpLog( + "mic UP -> send (unlocked) mode=$recordMode state=$recordUiState" + ) + if (isVoiceRecording || voiceRecorder != null) { + stopVoiceRecording(send = true) + } else { + setRecordUiState(RecordUiState.IDLE, "release-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() + finished = true + } else if (recordUiState == RecordUiState.PRESSING) { + val dx = change.position.x - pressStartX + val dy = change.position.y - pressStartY + val absDx = kotlin.math.abs(dx) + val absDy = kotlin.math.abs(dy) + if (absDx > maxAbsDx) maxAbsDx = absDx + if (absDy > maxAbsDy) maxAbsDy = absDy + } else if ( + recordUiState == RecordUiState.RECORDING || + recordUiState == RecordUiState.LOCKED + ) { + val dx = change.position.x - pressStartX + val dy = change.position.y - pressStartY + slideDx = dx + slideDy = dy + + if (recordUiState == RecordUiState.RECORDING) { + 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) { + setRecordUiState( + RecordUiState.LOCKED, + "slide-lock dy=${dy.toInt()}" + ) + } + } + } + change.consume() + } + + pendingLongPressJob?.cancel() + pendingLongPressJob = null + if (recordUiState == RecordUiState.PRESSING) { + setRecordUiState(RecordUiState.IDLE, "gesture-end") + resetGestureState() + } + } + }, + contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.Mic, - contentDescription = "Record voice message", + imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, + contentDescription = "Record message", tint = PrimaryBlue, modifier = Modifier.size(24.dp) ) From 47a6e208342f85bc08348e7482f8335808b1e292 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 20:39:30 +0500 Subject: [PATCH 04/32] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20composable=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20LockIcon,=20SlideToCance?= =?UTF-8?q?l,=20LockTooltip,=20VoiceWaveformBar,=20RecordLockedControls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) 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 a9e64c9..e0f8e3a 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 @@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -391,6 +392,329 @@ private fun VoiceButtonBlob( } } +private const val LOCK_HINT_PREF_KEY = "lock_record_hint_shown_count" +private const val LOCK_HINT_MAX_SHOWS = 3 + +@Composable +private fun LockIcon( + lockProgress: Float, + isLocked: Boolean, + isPaused: Boolean, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val progress = lockProgress.coerceIn(0f, 1f) + + val snapProgress by animateFloatAsState( + targetValue = if (isLocked || isPaused) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + label = "lock_snap" + ) + val pauseTransform by animateFloatAsState( + targetValue = if (isPaused || isLocked) 1f else 0f, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "lock_to_pause" + ) + + val bgColor = if (isDarkTheme) Color(0xFF2A2A3E) else Color(0xFFF0F0F0) + val iconColor = if (isDarkTheme) Color.White else Color(0xFF333333) + val shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f) + + val enterAlpha by animateFloatAsState( + targetValue = if (lockProgress > 0.01f || isLocked || isPaused) 1f else 0f, + animationSpec = tween(durationMillis = 150), + label = "lock_enter_alpha" + ) + + Canvas( + modifier = modifier + .graphicsLayer { alpha = enterAlpha } + ) { + val cx = size.width / 2f + val lockSize = size.minDimension + val moveProgress = if (isLocked || isPaused) 1f else progress + val currentRotation = if (isLocked || isPaused) { + -15f * snapProgress * (1f - snapProgress) + } else { + 9f * (1f - moveProgress) + } + + // Shadow first + drawRoundRect( + color = shadowColor, + topLeft = Offset(cx - lockSize / 2f - 2f, -2f), + size = androidx.compose.ui.geometry.Size(lockSize + 4f, lockSize * 1.38f + 4f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f + 2f) + ) + + // Background pill + val bgAlpha = 0.7f + 0.3f * moveProgress + drawRoundRect( + color = bgColor.copy(alpha = bgAlpha), + topLeft = Offset(cx - lockSize / 2f, 0f), + size = androidx.compose.ui.geometry.Size(lockSize, lockSize * 1.38f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f) + ) + + val bodyCx = cx + val bodyCy = lockSize * 0.78f + + // Transform to pause: body splits into two bars + if (pauseTransform > 0.01f) { + val gap = 3.3f * pauseTransform + val barW = lockSize * 0.12f + val barH = lockSize * 0.35f + drawRoundRect( + color = iconColor, + topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f) + ) + drawRoundRect( + color = iconColor, + topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f) + ) + } + + // Lock icon (fades out as pause transform progresses) + val lockAlpha = 1f - pauseTransform + if (lockAlpha > 0.01f) { + rotate(degrees = currentRotation, pivot = Offset(bodyCx, bodyCy)) { + val bodyW = lockSize * 0.38f + val bodyH = lockSize * 0.28f + drawRoundRect( + color = iconColor.copy(alpha = lockAlpha), + topLeft = Offset(bodyCx - bodyW / 2f, bodyCy - bodyH / 4f), + size = androidx.compose.ui.geometry.Size(bodyW, bodyH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize * 0.05f) + ) + val shackleW = lockSize * 0.26f + val shackleH = lockSize * 0.22f + val shackleStroke = lockSize * 0.047f + drawArc( + color = iconColor.copy(alpha = lockAlpha), + startAngle = 180f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(bodyCx - shackleW / 2f, bodyCy - bodyH / 4f - shackleH), + size = androidx.compose.ui.geometry.Size(shackleW, shackleH), + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = shackleStroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + ) + } + } + } +} + +@Composable +private fun SlideToCancel( + slideDx: Float, + cancelThresholdPx: Float, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) + val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f + + val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow") + val arrowOffset by arrowPulse.animateFloat( + initialValue = 0f, + targetValue = -3f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 750, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "slide_cancel_arrow_offset" + ) + + val textColor = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.4f) + + Row( + modifier = modifier + .graphicsLayer { + translationX = slideDx * 0.5f + alpha = textAlpha + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "◀", + color = textColor, + fontSize = 13.sp, + modifier = Modifier.graphicsLayer { translationX = arrowOffset } + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Slide to Cancel", + color = textColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } +} + +@Composable +private fun LockTooltip( + visible: Boolean, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = tween(durationMillis = 150), + label = "tooltip_alpha" + ) + + if (alpha > 0.01f) { + Row( + modifier = modifier + .graphicsLayer { this.alpha = alpha } + .background( + color = Color(0xFF333333), + shape = RoundedCornerShape(5.dp) + ) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Slide Up to Lock", + color = Color.White, + fontSize = 14.sp, + maxLines = 1 + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "↑", + color = Color.White, + fontSize = 14.sp + ) + } + } +} + +@Composable +private fun VoiceWaveformBar( + waves: List, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val barColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) + val barWidthDp = 2.dp + val barGapDp = 1.dp + val minBarHeightDp = 2.dp + val maxBarHeightDp = 20.dp + + Canvas(modifier = modifier.height(maxBarHeightDp)) { + val barWidthPx = barWidthDp.toPx() + val barGapPx = barGapDp.toPx() + val minH = minBarHeightDp.toPx() + val maxH = maxBarHeightDp.toPx() + val totalBarWidth = barWidthPx + barGapPx + val maxBars = (size.width / totalBarWidth).toInt().coerceAtLeast(1) + val displayWaves = if (waves.size > maxBars) waves.takeLast(maxBars) else waves + val cy = size.height / 2f + + displayWaves.forEachIndexed { index, level -> + val barH = minH + (maxH - minH) * level.coerceIn(0f, 1f) + val x = (maxBars - displayWaves.size + index) * totalBarWidth + drawRoundRect( + color = barColor, + topLeft = Offset(x, cy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barWidthPx, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidthPx / 2f) + ) + } + } +} + +@Composable +private fun RecordLockedControls( + isPaused: Boolean, + isDarkTheme: Boolean, + onDelete: () -> Unit, + onTogglePause: () -> Unit, + modifier: Modifier = Modifier +) { + val deleteBgColor = if (isDarkTheme) Color(0xFF444444) else Color(0xFFE0E0E0) + val deleteIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.8f) else Color(0xFF666666) + val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.3f) else Color(0xFF2D9CFF).copy(alpha = 0.2f) + val pauseIconColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Delete button + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(deleteBgColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDelete() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Delete recording", + tint = deleteIconColor, + modifier = Modifier.size(16.dp) + ) + } + + // Pause/Resume button + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(pauseBgColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onTogglePause() }, + contentAlignment = Alignment.Center + ) { + if (isPaused) { + Canvas(modifier = Modifier.size(12.dp)) { + val path = Path().apply { + moveTo(size.width * 0.2f, 0f) + lineTo(size.width, size.height / 2f) + lineTo(size.width * 0.2f, size.height) + close() + } + drawPath(path, color = pauseIconColor) + } + } else { + Canvas(modifier = Modifier.size(12.dp)) { + val barW = size.width * 0.25f + val gap = size.width * 0.15f + drawRoundRect( + color = pauseIconColor, + topLeft = Offset(size.width / 2f - gap - barW, 0f), + size = androidx.compose.ui.geometry.Size(barW, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f) + ) + drawRoundRect( + color = pauseIconColor, + topLeft = Offset(size.width / 2f + gap, 0f), + size = androidx.compose.ui.geometry.Size(barW, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f) + ) + } + } + } + } +} + /** * Message input bar and related components * Extracted from ChatDetailScreen.kt for better organization From 620200ca4429c8ad8a20ea629e97be872ca204e5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 20:44:36 +0500 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20LockIcon,=20?= =?UTF-8?q?SlideToCancel,=20waveform=20=D0=B8=20controls=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 203 +++++++++++++----- 1 file changed, 147 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index e0f8e3a..d228b0f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -896,6 +896,7 @@ fun MessageInputBar( var pressStartY by remember { mutableFloatStateOf(0f) } var slideDx by remember { mutableFloatStateOf(0f) } var slideDy by remember { mutableFloatStateOf(0f) } + var lockProgress by remember { mutableFloatStateOf(0f) } var pendingLongPressJob by remember { mutableStateOf(null) } var pendingRecordAfterPermission by remember { mutableStateOf(false) } var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } @@ -946,6 +947,7 @@ fun MessageInputBar( slideDy = 0f pressStartX = 0f pressStartY = 0f + lockProgress = 0f pendingLongPressJob?.cancel() pendingLongPressJob = null } @@ -1197,6 +1199,12 @@ fun MessageInputBar( val cancelDragThresholdPx = with(density) { 92.dp.toPx() } val lockDragThresholdPx = with(density) { 70.dp.toPx() } + var showLockTooltip by remember { mutableStateOf(false) } + val lockHintShownCount = remember { + context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) + .getInt(LOCK_HINT_PREF_KEY, 0) + } + fun tryStartRecordingForCurrentMode(): Boolean { return if (recordMode == RecordMode.VOICE) { setRecordUiState(RecordUiState.RECORDING, "hold-threshold-passed") @@ -1233,6 +1241,29 @@ fun MessageInputBar( } } + LaunchedEffect(recordUiState) { + if (recordUiState == RecordUiState.RECORDING && lockHintShownCount < LOCK_HINT_MAX_SHOWS) { + delay(200) + if (recordUiState == RecordUiState.RECORDING) { + showLockTooltip = true + context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) + .edit() + .putInt(LOCK_HINT_PREF_KEY, lockHintShownCount + 1) + .apply() + delay(3000) + showLockTooltip = false + } + } else { + showLockTooltip = false + } + } + + LaunchedEffect(lockProgress) { + if (lockProgress > 0.2f) { + showLockTooltip = false + } + } + DisposableEffect(Unit) { onDispose { pendingRecordAfterPermission = false @@ -1909,9 +1940,9 @@ fun MessageInputBar( .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp) .onGloballyPositioned { coordinates -> - recordingInputRowHeightPx = coordinates.size.height - recordingInputRowY = coordinates.positionInWindow().y - }, + recordingInputRowHeightPx = coordinates.size.height + recordingInputRowY = coordinates.positionInWindow().y + }, contentAlignment = Alignment.CenterEnd ) { Box( @@ -1920,8 +1951,9 @@ fun MessageInputBar( .height(40.dp) .clip(RoundedCornerShape(20.dp)) .background(recordingPanelColor) - .padding(start = 13.dp, end = 94.dp) // record panel paddings + .padding(start = 13.dp, end = 94.dp) ) { + // Left: blink dot + timer (all states) Row( modifier = Modifier .align(Alignment.CenterStart) @@ -1931,8 +1963,25 @@ fun MessageInputBar( }, verticalAlignment = Alignment.CenterVertically ) { - RecordBlinkDot(isDarkTheme = isDarkTheme) - Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot + if (recordUiState == RecordUiState.PAUSED) { + // Static dot (no blink) when paused + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) + ) + ) + } + } else { + RecordBlinkDot(isDarkTheme = isDarkTheme) + } + Spacer(modifier = Modifier.width(6.dp)) Text( text = formatVoiceRecordTimer(voiceElapsedMs), color = recordingTextColor, @@ -1941,67 +1990,107 @@ fun MessageInputBar( ) } - Text( - text = - if (recordUiState == RecordUiState.LOCKED) { - "CANCEL" - } else { - "Slide left to cancel • up to lock" + // Center: SlideToCancel or Waveform+Controls + AnimatedContent( + targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED, + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + alpha = recordUiAlpha + translationX = with(density) { recordUiShift.toPx() } }, - color = if (recordUiState == RecordUiState.LOCKED) PrimaryBlue else recordingTextColor.copy(alpha = 0.82f), - fontSize = if (recordUiState == RecordUiState.LOCKED) 15.sp else 13.sp, - fontWeight = if (recordUiState == RecordUiState.LOCKED) FontWeight.Bold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .align(Alignment.Center) - .graphicsLayer { - alpha = recordUiAlpha - translationX = with(density) { recordUiShift.toPx() } - } - .then( - if (recordUiState == RecordUiState.LOCKED) { - Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - inputJumpLog( - "tap CANCEL (locked) mode=$recordMode state=$recordUiState " + - "voice=$isVoiceRecording kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + - inputHeightsSnapshot() - ) - stopVoiceRecording(send = false) + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(200)) + }, + label = "record_center_content" + ) { isLockedOrPaused -> + if (isLockedOrPaused) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + VoiceWaveformBar( + waves = voiceWaves, + isDarkTheme = isDarkTheme, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + RecordLockedControls( + isPaused = recordUiState == RecordUiState.PAUSED, + isDarkTheme = isDarkTheme, + onDelete = { + inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState") + stopVoiceRecording(send = false) + }, + onTogglePause = { + inputJumpLog("tap PAUSE/RESUME mode=$recordMode state=$recordUiState") + if (recordUiState == RecordUiState.PAUSED) { + resumeVoiceRecording() + } else { + pauseVoiceRecording() } - } else { - Modifier } ) - ) + } + } else { + SlideToCancel( + slideDx = slideDx, + cancelThresholdPx = cancelDragThresholdPx, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxWidth() + ) + } + } } + // Mic button area with LockIcon overlay Box( modifier = Modifier .size(40.dp) .offset(x = 8.dp), contentAlignment = Alignment.Center ) { + // LockIcon positioned above the mic button + if (recordUiState == RecordUiState.RECORDING || + recordUiState == RecordUiState.LOCKED || + recordUiState == RecordUiState.PAUSED + ) { + LockIcon( + lockProgress = lockProgress, + isLocked = recordUiState == RecordUiState.LOCKED, + isPaused = recordUiState == RecordUiState.PAUSED, + isDarkTheme = isDarkTheme, + modifier = Modifier + .size(36.dp) + .offset(y = (-60).dp) + .zIndex(10f) + ) + + // Tooltip + if (showLockTooltip && recordUiState == RecordUiState.RECORDING) { + LockTooltip( + visible = showLockTooltip, + isDarkTheme = isDarkTheme, + modifier = Modifier + .offset(x = (-80).dp, y = (-60).dp) + .zIndex(11f) + ) + } + } + VoiceButtonBlob( voiceLevel = voiceLevel, isDarkTheme = isDarkTheme, - modifier = - Modifier - .fillMaxSize() - .graphicsLayer { - // Visual-only enlargement like Telegram record circle, - // while keeping layout hitbox at normal input size. - scaleX = 2.05f - scaleY = 2.05f - clip = false - } + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = 2.05f + scaleY = 2.05f + clip = false + } ) - if (recordUiState == RecordUiState.LOCKED) { + if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { Box( modifier = Modifier .requiredSize(82.dp) @@ -2017,7 +2106,7 @@ fun MessageInputBar( indication = null ) { inputJumpLog( - "tap SEND (locked) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " + + "tap SEND (locked/paused) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " + "kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) stopVoiceRecording(send = true) @@ -2033,11 +2122,10 @@ fun MessageInputBar( } } else { Box( - modifier = - Modifier - .requiredSize(82.dp) - .clip(CircleShape) - .background(PrimaryBlue.copy(alpha = 0.92f)), + modifier = Modifier + .requiredSize(82.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.92f)), contentAlignment = Alignment.Center ) { Icon( @@ -2257,6 +2345,7 @@ fun MessageInputBar( slideDy = dy if (recordUiState == RecordUiState.RECORDING) { + lockProgress = ((-dy) / lockDragThresholdPx).coerceIn(0f, 1f) if (dx <= -cancelDragThresholdPx) { inputJumpLog( "gesture CANCEL dx=${dx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" @@ -2266,6 +2355,8 @@ fun MessageInputBar( resetGestureState() finished = true } else if (dy <= -lockDragThresholdPx) { + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) + lockProgress = 1f setRecordUiState( RecordUiState.LOCKED, "slide-lock dy=${dy.toInt()}" From 3e3f501b9b12b1e4077e7e8ca401fe16ec3bfc82 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 20:47:18 +0500 Subject: [PATCH 06/32] =?UTF-8?q?test:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20unit=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20helper=20=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D1=81=D0=B0,=20=D0=BE=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 4 +- .../ui/chats/input/VoiceRecordHelpersTest.kt | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/rosetta/messenger/ui/chats/input/VoiceRecordHelpersTest.kt 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 d228b0f..819aeb7 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 @@ -108,7 +108,7 @@ private fun bytesToHexLower(bytes: ByteArray): String { return out.toString() } -private fun compressVoiceWaves(source: List, targetLength: Int): List { +internal fun compressVoiceWaves(source: List, targetLength: Int): List { if (targetLength <= 0) return emptyList() if (source.isEmpty()) return List(targetLength) { 0f } if (source.size == targetLength) return source @@ -142,7 +142,7 @@ private fun compressVoiceWaves(source: List, targetLength: Int): List(), compressVoiceWaves(listOf(1f), 0)) + } + + @Test + fun `compressVoiceWaves upsamples via interpolation`() { + val source = listOf(0.0f, 1.0f) + val result = compressVoiceWaves(source, 3) + assertEquals(3, result.size) + assertEquals(0.0f, result[0], 0.01f) + assertEquals(0.5f, result[1], 0.01f) + assertEquals(1.0f, result[2], 0.01f) + } +} From b6055c98a5d43d383d397ec5e76577b6ff6ef28b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 21:26:40 +0500 Subject: [PATCH 07/32] =?UTF-8?q?polish:=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=201:1?= =?UTF-8?q?=20=D1=81=20Telegram=20=E2=80=94=20lock=20growth,=20staggered?= =?UTF-8?q?=20snap,=20EaseOutQuint,=20exit=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LockIcon: размер 50dp→36dp при свайпе, Y-позиция анимируется - Staggered snap: rotation(250ms EASE_OUT_QUINT) + translate(350ms, delay 100ms) - Двухфазный snap rotation с snapRotateBackProgress (порог 40%) - SlideToCancel: пульсация ±6dp при >80%, демпфирование 0.3 - Send кнопка: scale-анимация 0→1 (150ms) - Exit: AnimatedVisibility с fadeOut+shrinkVertically (300ms) - Cancel distance: 92dp→140dp - Фикс прыжка инпута при смене Voice/Video Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 72 ++++++++++++++----- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 819aeb7..693a113 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 @@ -88,6 +88,8 @@ import java.util.UUID import kotlin.math.PI import kotlin.math.sin +private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f) + private fun truncateEmojiSafe(text: String, maxLen: Int): String { if (text.length <= maxLen) return text var cutAt = maxLen @@ -404,14 +406,21 @@ private fun LockIcon( modifier: Modifier = Modifier ) { val progress = lockProgress.coerceIn(0f, 1f) + val lockedOrPaused = isLocked || isPaused - val snapProgress by animateFloatAsState( - targetValue = if (isLocked || isPaused) 1f else 0f, - animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), - label = "lock_snap" + // Staggered snap animations (Fix #3) + val snapRotation by animateFloatAsState( + targetValue = if (lockedOrPaused) 1f else 0f, + animationSpec = tween(durationMillis = 250, easing = EaseOutQuint), + label = "lock_snap_rotation" + ) + val snapTranslate by animateFloatAsState( + targetValue = if (lockedOrPaused) 1f else 0f, + animationSpec = tween(durationMillis = 350, delayMillis = 100, easing = LinearOutSlowInEasing), + label = "lock_snap_translate" ) val pauseTransform by animateFloatAsState( - targetValue = if (isPaused || isLocked) 1f else 0f, + targetValue = if (lockedOrPaused) 1f else 0f, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), label = "lock_to_pause" ) @@ -421,7 +430,7 @@ private fun LockIcon( val shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f) val enterAlpha by animateFloatAsState( - targetValue = if (lockProgress > 0.01f || isLocked || isPaused) 1f else 0f, + targetValue = if (lockProgress > 0.01f || lockedOrPaused) 1f else 0f, animationSpec = tween(durationMillis = 150), label = "lock_enter_alpha" ) @@ -432,9 +441,13 @@ private fun LockIcon( ) { val cx = size.width / 2f val lockSize = size.minDimension - val moveProgress = if (isLocked || isPaused) 1f else progress - val currentRotation = if (isLocked || isPaused) { - -15f * snapProgress * (1f - snapProgress) + val moveProgress = if (lockedOrPaused) 1f else progress + + // Dual-phase snap rotation (Fix #4) — Telegram formula + val snapRotateBackProgress = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f + val currentRotation = if (lockedOrPaused) { + 9f * (1f - moveProgress) * (1f - snapRotation) - + 15f * snapRotation * (1f - snapRotateBackProgress) } else { 9f * (1f - moveProgress) } @@ -520,12 +533,14 @@ private fun SlideToCancel( val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f + // Fix #5: Arrow pulsation ±6dp when slideRatio > 0.8, else ±3dp + val arrowAmplitude = if (slideRatio > 0.8f) -6f else -3f val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow") val arrowOffset by arrowPulse.animateFloat( initialValue = 0f, - targetValue = -3f, + targetValue = arrowAmplitude, animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 750, easing = LinearEasing), + animation = tween(durationMillis = 500, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "slide_cancel_arrow_offset" @@ -533,10 +548,11 @@ private fun SlideToCancel( val textColor = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.4f) + // Fix #6: damping 0.3 (Telegram-style) Row( modifier = modifier .graphicsLayer { - translationX = slideDx * 0.5f + translationX = slideDx * 0.3f alpha = textAlpha }, verticalAlignment = Alignment.CenterVertically, @@ -961,7 +977,6 @@ fun MessageInputBar( isKeyboardVisible || coordinator.isEmojiBoxVisible || isVoiceRecordTransitioning || - recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.PAUSED val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput @@ -1139,7 +1154,6 @@ fun MessageInputBar( isKeyboardVisible || coordinator.isEmojiBoxVisible || isVoiceRecordTransitioning || - recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.PAUSED val navPad = hasNativeNavigationBar && !pinBottom "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " + @@ -1196,7 +1210,7 @@ fun MessageInputBar( } val holdToRecordDelayMs = 260L - val cancelDragThresholdPx = with(density) { 92.dp.toPx() } + val cancelDragThresholdPx = with(density) { 140.dp.toPx() } val lockDragThresholdPx = with(density) { 70.dp.toPx() } var showLockTooltip by remember { mutableStateOf(false) } @@ -1907,7 +1921,12 @@ fun MessageInputBar( } } - if (isVoiceRecording) { + // Fix #8: AnimatedVisibility for smooth exit + androidx.compose.animation.AnimatedVisibility( + visible = isVoiceRecording, + enter = fadeIn(tween(180)) + expandVertically(tween(180)), + exit = fadeOut(tween(300)) + shrinkVertically(tween(300)) + ) { val recordingPanelColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) val recordingTextColor = @@ -2055,14 +2074,18 @@ fun MessageInputBar( recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED ) { + // Fix #1: Lock grows 50dp→36dp as lockProgress 0→1 + // Fix #2: Y-position animates closer to mic + val lockSizeDp = (50.dp - 14.dp * lockProgress) + val lockYOffset = ((-60).dp + 14.dp * lockProgress) LockIcon( lockProgress = lockProgress, isLocked = recordUiState == RecordUiState.LOCKED, isPaused = recordUiState == RecordUiState.PAUSED, isDarkTheme = isDarkTheme, modifier = Modifier - .size(36.dp) - .offset(y = (-60).dp) + .size(lockSizeDp) + .offset(y = lockYOffset) .zIndex(10f) ) @@ -2090,10 +2113,20 @@ fun MessageInputBar( } ) + // Fix #7: Send button with scale animation + 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" + ) if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { Box( modifier = Modifier .requiredSize(82.dp) + .graphicsLayer { + scaleX = sendScale + scaleY = sendScale + } .shadow( elevation = 10.dp, shape = CircleShape, @@ -2138,7 +2171,8 @@ fun MessageInputBar( } } } - } else { + } + if (!isVoiceRecording) { Row( modifier = Modifier .fillMaxWidth() From b13cdb7ea132186df4571947403ccc1b091f75f9 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 21:41:29 +0500 Subject: [PATCH 08/32] =?UTF-8?q?fix:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D1=82=D1=8C=20layout=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=20=E2=80=94=20layered=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20cramming=20=D0=B2=2040dp=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Panel bar (timer + slide-to-cancel/waveform) как Layer 1 - Mic/Send circle (48dp) как overlay Layer 2 поверх панели - LockIcon как Layer 3 над кругом через graphicsLayer (без clip) - Убран padding(end=94dp), заменён на padding(end=44dp) - Убран offset(x=8dp) который толкал круг за экран - Controls увеличены 28dp→36dp для лучшей тач-зоны - Blob scale 2.05→1.8 пропорционально новому 48dp размеру Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 693a113..e6b9259 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 @@ -670,7 +670,7 @@ private fun RecordLockedControls( // Delete button Box( modifier = Modifier - .size(28.dp) + .size(36.dp) .clip(CircleShape) .background(deleteBgColor) .clickable( @@ -683,14 +683,14 @@ private fun RecordLockedControls( imageVector = Icons.Default.Close, contentDescription = "Delete recording", tint = deleteIconColor, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(18.dp) ) } // Pause/Resume button Box( modifier = Modifier - .size(28.dp) + .size(36.dp) .clip(CircleShape) .background(pauseBgColor) .clickable( @@ -1921,7 +1921,10 @@ fun MessageInputBar( } } - // Fix #8: AnimatedVisibility for smooth exit + // ── Recording panel (layered architecture) ── + // 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) androidx.compose.animation.AnimatedVisibility( visible = isVoiceRecording, enter = fadeIn(tween(180)) + expandVertically(tween(180)), @@ -1953,26 +1956,28 @@ fun MessageInputBar( label = "record_ui_shift" ) + // Outer Box — no clip, allows children to overflow Box( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp) + .padding(horizontal = 8.dp, vertical = 8.dp) .onGloballyPositioned { coordinates -> recordingInputRowHeightPx = coordinates.size.height recordingInputRowY = coordinates.positionInWindow().y - }, - contentAlignment = Alignment.CenterEnd + } ) { + // ── Layer 1: Panel bar ── Box( modifier = Modifier .fillMaxWidth() .height(40.dp) + .padding(end = 44.dp) // space for circle overlap .clip(RoundedCornerShape(20.dp)) .background(recordingPanelColor) - .padding(start = 13.dp, end = 94.dp) + .padding(horizontal = 13.dp) ) { - // Left: blink dot + timer (all states) + // Left: blink dot + timer Row( modifier = Modifier .align(Alignment.CenterStart) @@ -1983,7 +1988,6 @@ fun MessageInputBar( verticalAlignment = Alignment.CenterVertically ) { if (recordUiState == RecordUiState.PAUSED) { - // Static dot (no blink) when paused Box( modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center @@ -2009,7 +2013,7 @@ fun MessageInputBar( ) } - // Center: SlideToCancel or Waveform+Controls + // Center content: SlideToCancel or Waveform+Controls AnimatedContent( targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED, modifier = Modifier @@ -2062,22 +2066,22 @@ fun MessageInputBar( } } - // Mic button area with LockIcon overlay + // ── Layer 2: Mic/Send circle overlay ── + // Positioned at right edge, overlapping the panel Box( modifier = Modifier - .size(40.dp) - .offset(x = 8.dp), + .size(48.dp) + .align(Alignment.CenterEnd) + .zIndex(5f), contentAlignment = Alignment.Center ) { - // LockIcon positioned above the mic button + // ── Layer 3: LockIcon above circle ── if (recordUiState == RecordUiState.RECORDING || recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED ) { - // Fix #1: Lock grows 50dp→36dp as lockProgress 0→1 - // Fix #2: Y-position animates closer to mic val lockSizeDp = (50.dp - 14.dp * lockProgress) - val lockYOffset = ((-60).dp + 14.dp * lockProgress) + val lockYOffset = ((-56).dp + 14.dp * lockProgress) LockIcon( lockProgress = lockProgress, isLocked = recordUiState == RecordUiState.LOCKED, @@ -2085,35 +2089,42 @@ fun MessageInputBar( isDarkTheme = isDarkTheme, modifier = Modifier .size(lockSizeDp) - .offset(y = lockYOffset) + .graphicsLayer { + translationY = with(density) { lockYOffset.toPx() } + clip = false + } .zIndex(10f) ) - // Tooltip if (showLockTooltip && recordUiState == RecordUiState.RECORDING) { LockTooltip( visible = showLockTooltip, isDarkTheme = isDarkTheme, modifier = Modifier - .offset(x = (-80).dp, y = (-60).dp) + .graphicsLayer { + translationX = with(density) { (-80).dp.toPx() } + translationY = with(density) { (-56).dp.toPx() } + clip = false + } .zIndex(11f) ) } } + // Blob animation (visual-only enlargement) VoiceButtonBlob( voiceLevel = voiceLevel, isDarkTheme = isDarkTheme, modifier = Modifier - .fillMaxSize() + .size(48.dp) .graphicsLayer { - scaleX = 2.05f - scaleY = 2.05f + scaleX = 1.8f + scaleY = 1.8f clip = false } ) - // Fix #7: Send button with scale animation + // Send or Mic button val sendScale by animateFloatAsState( targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f, animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), @@ -2122,13 +2133,13 @@ fun MessageInputBar( if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { Box( modifier = Modifier - .requiredSize(82.dp) + .size(48.dp) .graphicsLayer { scaleX = sendScale scaleY = sendScale } .shadow( - elevation = 10.dp, + elevation = 6.dp, shape = CircleShape, clip = false ) @@ -2150,22 +2161,22 @@ fun MessageInputBar( imageVector = TelegramSendIcon, contentDescription = "Send voice message", tint = Color.White, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(22.dp) ) } } else { Box( modifier = Modifier - .requiredSize(82.dp) + .size(48.dp) .clip(CircleShape) - .background(PrimaryBlue.copy(alpha = 0.92f)), + .background(PrimaryBlue), contentAlignment = Alignment.Center ) { Icon( imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, contentDescription = null, tint = Color.White, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(22.dp) ) } } From 78fbe0b3c8db73acaf85af182bf1325405a6ad67 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 21:59:03 +0500 Subject: [PATCH 09/32] =?UTF-8?q?fix:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20recording=20layout=201:1=20?= =?UTF-8?q?=D1=81=20Telegram=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=86=D0=B8=D0=B8=20=D0=B8=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram dimensions: - Circle: 48dp layout → 82dp visual (scale 1.71x), как circleRadius=41dp - Lock: 50dp→36dp pill, 70dp выше центра круга - Panel bar: full width Row с end=52dp для overlap - Blob: 1.7x scale = 82dp visual (Telegram blob minRadius) - Controls: 36dp (delete + pause) - Tooltip: 90dp левее, 70dp выше Layout architecture: - Layer 1: Panel bar (Row с clip RoundedCornerShape) - Layer 2: Circle overlay (graphicsLayer scale, NO clip) - Layer 3: Lock overlay (graphicsLayer translationY, NO clip) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 85 ++++++++++--------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index e6b9259..7f2aed8 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 @@ -1956,7 +1956,9 @@ fun MessageInputBar( label = "record_ui_shift" ) - // Outer Box — no clip, allows children to overflow + // ── Telegram-style recording layout ── + // Telegram uses separate overlay layers (RecordCircle 194dp, ControlsView 250dp) + // We replicate with Box layers + graphicsLayer for overflow Box( modifier = Modifier .fillMaxWidth() @@ -1967,20 +1969,20 @@ fun MessageInputBar( recordingInputRowY = coordinates.positionInWindow().y } ) { - // ── Layer 1: Panel bar ── - Box( + // ── Layer 1: Panel bar (timer + center) ── + // Telegram: full-width bar, circle overlaps right edge + Row( modifier = Modifier .fillMaxWidth() .height(40.dp) - .padding(end = 44.dp) // space for circle overlap .clip(RoundedCornerShape(20.dp)) .background(recordingPanelColor) - .padding(horizontal = 13.dp) + .padding(start = 13.dp, end = 52.dp), // 52dp = half-circle overlap + verticalAlignment = Alignment.CenterVertically ) { - // Left: blink dot + timer + // Blink dot + timer Row( modifier = Modifier - .align(Alignment.CenterStart) .graphicsLayer { alpha = recordUiAlpha translationX = with(density) { recordUiShift.toPx() } @@ -1988,18 +1990,9 @@ fun MessageInputBar( verticalAlignment = Alignment.CenterVertically ) { if (recordUiState == RecordUiState.PAUSED) { - Box( - modifier = Modifier.size(28.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background( - if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) - ) - ) + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape) + .background(if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D))) } } else { RecordBlinkDot(isDarkTheme = isDarkTheme) @@ -2013,11 +2006,13 @@ fun MessageInputBar( ) } - // Center content: SlideToCancel or Waveform+Controls + Spacer(modifier = Modifier.width(12.dp)) + + // Center: SlideToCancel or Waveform+Controls AnimatedContent( targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED, modifier = Modifier - .align(Alignment.Center) + .weight(1f) .graphicsLayer { alpha = recordUiAlpha translationX = with(density) { recordUiShift.toPx() } @@ -2066,22 +2061,24 @@ fun MessageInputBar( } } - // ── Layer 2: Mic/Send circle overlay ── - // Positioned at right edge, overlapping the panel + // ── 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 .zIndex(5f), contentAlignment = Alignment.Center ) { - // ── Layer 3: LockIcon above circle ── + // Lock icon: floats ~70dp above circle center (Telegram: ~92dp) if (recordUiState == RecordUiState.RECORDING || recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED ) { - val lockSizeDp = (50.dp - 14.dp * lockProgress) - val lockYOffset = ((-56).dp + 14.dp * lockProgress) + val lockSizeDp = 50.dp - 14.dp * lockProgress + val lockYDp = -70.dp + 14.dp * lockProgress LockIcon( lockProgress = lockProgress, isLocked = recordUiState == RecordUiState.LOCKED, @@ -2090,7 +2087,7 @@ fun MessageInputBar( modifier = Modifier .size(lockSizeDp) .graphicsLayer { - translationY = with(density) { lockYOffset.toPx() } + translationY = with(density) { lockYDp.toPx() } clip = false } .zIndex(10f) @@ -2102,8 +2099,8 @@ fun MessageInputBar( isDarkTheme = isDarkTheme, modifier = Modifier .graphicsLayer { - translationX = with(density) { (-80).dp.toPx() } - translationY = with(density) { (-56).dp.toPx() } + translationX = with(density) { (-90).dp.toPx() } + translationY = with(density) { (-70).dp.toPx() } clip = false } .zIndex(11f) @@ -2111,38 +2108,37 @@ fun MessageInputBar( } } - // Blob animation (visual-only enlargement) + // Blob: 48dp base → 1.7x = ~82dp visual (matches Telegram circleRadius 41dp) VoiceButtonBlob( voiceLevel = voiceLevel, isDarkTheme = isDarkTheme, modifier = Modifier .size(48.dp) .graphicsLayer { - scaleX = 1.8f - scaleY = 1.8f + scaleX = 1.7f + scaleY = 1.7f clip = false } ) - // Send or Mic button + // Solid circle: 48dp layout, scaled to 82dp visual 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) .graphicsLayer { - scaleX = sendScale - scaleY = sendScale - } - .shadow( - elevation = 6.dp, - shape = CircleShape, + scaleX = circleScale * sendScale + scaleY = circleScale * sendScale clip = false - ) + shadowElevation = 8f + shape = CircleShape + } .clip(CircleShape) .background(PrimaryBlue) .clickable( @@ -2161,13 +2157,18 @@ fun MessageInputBar( imageVector = TelegramSendIcon, contentDescription = "Send voice message", tint = Color.White, - modifier = Modifier.size(22.dp) + modifier = Modifier.size(24.dp) ) } } else { Box( modifier = Modifier .size(48.dp) + .graphicsLayer { + scaleX = circleScale + scaleY = circleScale + clip = false + } .clip(CircleShape) .background(PrimaryBlue), contentAlignment = Alignment.Center @@ -2176,7 +2177,7 @@ fun MessageInputBar( imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, contentDescription = null, tint = Color.White, - modifier = Modifier.size(22.dp) + modifier = Modifier.size(24.dp) ) } } From 946ba7838cb42e7850923e97f82e98abd1752a7e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 22:01:53 +0500 Subject: [PATCH 10/32] =?UTF-8?q?fix:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20LockIcon=201:1=20=D1=81=20Tele?= =?UTF-8?q?gram=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BC=D0=BE=D0=BA=20=D1=81=20?= =?UTF-8?q?keyhole=20=D0=B8=20idle=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram-exact lock icon: - Body: 16×16dp прямоугольник, radius 3dp (заливка) - Shackle: 8×8dp полукруг (stroke 1.7dp) + две ножки - Левая ножка: idle "breathing" animation (1.2s cycle) - Левая ножка: удлиняется при snap lock - Keyhole: 4dp точка в центре body (цвет фона) - Pause transform: body раздваивается с gap 1.66dp (Telegram exact) - Pill background: 36×50dp с тенью - Lock виден сразу при начале записи (не ждёт свайпа) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 178 +++++++++++------- 1 file changed, 112 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 7f2aed8..308a277 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 @@ -408,118 +408,164 @@ private fun LockIcon( val progress = lockProgress.coerceIn(0f, 1f) val lockedOrPaused = isLocked || isPaused - // Staggered snap animations (Fix #3) - val snapRotation by animateFloatAsState( + // Staggered snap animations — Telegram timing + val snapAnim by animateFloatAsState( targetValue = if (lockedOrPaused) 1f else 0f, animationSpec = tween(durationMillis = 250, easing = EaseOutQuint), - label = "lock_snap_rotation" - ) - val snapTranslate by animateFloatAsState( - targetValue = if (lockedOrPaused) 1f else 0f, - animationSpec = tween(durationMillis = 350, delayMillis = 100, easing = LinearOutSlowInEasing), - label = "lock_snap_translate" + label = "lock_snap" ) val pauseTransform by animateFloatAsState( targetValue = if (lockedOrPaused) 1f else 0f, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + animationSpec = tween(durationMillis = 300, delayMillis = 150, easing = FastOutSlowInEasing), label = "lock_to_pause" ) + // Idle "breathing" animation for shackle + val idlePhase by rememberInfiniteTransition(label = "lock_idle").animateFloat( + initialValue = 0f, targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing), RepeatMode.Reverse), + label = "lock_idle_phase" + ) val bgColor = if (isDarkTheme) Color(0xFF2A2A3E) else Color(0xFFF0F0F0) val iconColor = if (isDarkTheme) Color.White else Color(0xFF333333) val shadowColor = if (isDarkTheme) Color.Black.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.15f) + val bgPaintColor = if (isDarkTheme) Color(0xFF3A3A4E) else Color(0xFFE8E8E8) + // Lock is always visible during recording (Telegram shows it immediately) val enterAlpha by animateFloatAsState( - targetValue = if (lockProgress > 0.01f || lockedOrPaused) 1f else 0f, - animationSpec = tween(durationMillis = 150), + targetValue = 1f, + animationSpec = tween(durationMillis = 200), label = "lock_enter_alpha" ) Canvas( - modifier = modifier - .graphicsLayer { alpha = enterAlpha } + modifier = modifier.graphicsLayer { alpha = enterAlpha } ) { val cx = size.width / 2f - val lockSize = size.minDimension + val dp1 = size.width / 36f // normalize to 36dp base val moveProgress = if (lockedOrPaused) 1f else progress - // Dual-phase snap rotation (Fix #4) — Telegram formula - val snapRotateBackProgress = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f - val currentRotation = if (lockedOrPaused) { - 9f * (1f - moveProgress) * (1f - snapRotation) - - 15f * snapRotation * (1f - snapRotateBackProgress) + // Telegram rotation: dual-phase snap + val snapRotateBack = if (moveProgress > 0.4f) 1f else moveProgress / 0.4f + val rotation = if (lockedOrPaused) { + 9f * (1f - moveProgress) * (1f - snapAnim) - + 15f * snapAnim * (1f - snapRotateBack) } else { 9f * (1f - moveProgress) } - // Shadow first + // ── Background pill with shadow ── + val pillW = 36f * dp1 + val pillH = 50f * dp1 + val pillLeft = cx - pillW / 2f + val pillTop = 0f + val pillRadius = pillW / 2f + + // Shadow drawRoundRect( color = shadowColor, - topLeft = Offset(cx - lockSize / 2f - 2f, -2f), - size = androidx.compose.ui.geometry.Size(lockSize + 4f, lockSize * 1.38f + 4f), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f + 2f) + topLeft = Offset(pillLeft - 3f * dp1, pillTop - 2f * dp1), + size = androidx.compose.ui.geometry.Size(pillW + 6f * dp1, pillH + 4f * dp1), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(pillRadius + 3f * dp1) ) - - // Background pill - val bgAlpha = 0.7f + 0.3f * moveProgress + // Pill background drawRoundRect( - color = bgColor.copy(alpha = bgAlpha), - topLeft = Offset(cx - lockSize / 2f, 0f), - size = androidx.compose.ui.geometry.Size(lockSize, lockSize * 1.38f), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize / 2f) + color = bgColor, + topLeft = Offset(pillLeft, pillTop), + size = androidx.compose.ui.geometry.Size(pillW, pillH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(pillRadius) ) + // ── Lock icon drawing (Telegram-exact) ── + // Body: 16dp × 16dp centered, with corner radius 3dp + val bodyW = 16f * dp1 + val bodyH = 16f * dp1 + val bodyRadius = 3f * dp1 val bodyCx = cx - val bodyCy = lockSize * 0.78f + val bodyCy = pillTop + pillH * 0.62f // body center in lower part of pill + val bodyLeft = bodyCx - bodyW / 2f + val bodyTop = bodyCy - bodyH / 2f - // Transform to pause: body splits into two bars - if (pauseTransform > 0.01f) { - val gap = 3.3f * pauseTransform - val barW = lockSize * 0.12f - val barH = lockSize * 0.35f - drawRoundRect( - color = iconColor, - topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f), - size = androidx.compose.ui.geometry.Size(barW, barH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f) - ) - drawRoundRect( - color = iconColor, - topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f), - size = androidx.compose.ui.geometry.Size(barW, barH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 4f) - ) - } + // Shackle: 8dp × 8dp arc above body, stroke 1.7dp + val shackleW = 8f * dp1 + val shackleH = 8f * dp1 + val shackleStroke = 1.7f * dp1 + val shackleLeft = bodyCx - shackleW / 2f + val shackleTop = bodyTop - shackleH * 0.7f - 2f * dp1 - // Lock icon (fades out as pause transform progresses) - val lockAlpha = 1f - pauseTransform - if (lockAlpha > 0.01f) { - rotate(degrees = currentRotation, pivot = Offset(bodyCx, bodyCy)) { - val bodyW = lockSize * 0.38f - val bodyH = lockSize * 0.28f - drawRoundRect( - color = iconColor.copy(alpha = lockAlpha), - topLeft = Offset(bodyCx - bodyW / 2f, bodyCy - bodyH / 4f), - size = androidx.compose.ui.geometry.Size(bodyW, bodyH), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(lockSize * 0.05f) - ) - val shackleW = lockSize * 0.26f - val shackleH = lockSize * 0.22f - val shackleStroke = lockSize * 0.047f + val lockIconAlpha = 1f - pauseTransform + val idleOffset = idlePhase * 2f * dp1 * (1f - moveProgress) // breathing on left leg + + if (lockIconAlpha > 0.01f) { + rotate(degrees = rotation, pivot = Offset(bodyCx, bodyCy)) { + // Shackle arc (half circle) drawArc( - color = iconColor.copy(alpha = lockAlpha), + color = iconColor.copy(alpha = lockIconAlpha), startAngle = 180f, sweepAngle = 180f, useCenter = false, - topLeft = Offset(bodyCx - shackleW / 2f, bodyCy - bodyH / 4f - shackleH), + topLeft = Offset(shackleLeft, shackleTop), size = androidx.compose.ui.geometry.Size(shackleW, shackleH), style = androidx.compose.ui.graphics.drawscope.Stroke( width = shackleStroke, cap = androidx.compose.ui.graphics.StrokeCap.Round ) ) + // Right leg (fixed) + drawLine( + color = iconColor.copy(alpha = lockIconAlpha), + start = Offset(shackleLeft + shackleW, shackleTop + shackleH / 2f), + end = Offset(shackleLeft + shackleW, bodyTop + 2f * dp1), + strokeWidth = shackleStroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + // Left leg (animated — idle breathing + lock closing) + val leftLegEnd = bodyTop + 2f * dp1 + idleOffset + + 4f * dp1 * snapAnim * (1f - moveProgress) + drawLine( + color = iconColor.copy(alpha = lockIconAlpha), + start = Offset(shackleLeft, shackleTop + shackleH / 2f), + end = Offset(shackleLeft, leftLegEnd), + strokeWidth = shackleStroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + // Body (filled rounded rect) + drawRoundRect( + color = iconColor.copy(alpha = lockIconAlpha), + topLeft = Offset(bodyLeft, bodyTop), + size = androidx.compose.ui.geometry.Size(bodyW, bodyH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius) + ) + + // Keyhole dot (Telegram: dpf2(2) radius at center) + drawCircle( + color = bgPaintColor.copy(alpha = lockIconAlpha), + radius = 2f * dp1, + center = Offset(bodyCx, bodyCy) + ) } } + + // ── Pause transform: body splits into two bars ── + if (pauseTransform > 0.01f) { + val gap = 1.66f * dp1 * pauseTransform + val barW = 4f * dp1 + val barH = 14f * dp1 + val barRadius = 1.5f * dp1 + drawRoundRect( + color = iconColor.copy(alpha = pauseTransform), + topLeft = Offset(bodyCx - gap - barW, bodyCy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius) + ) + drawRoundRect( + color = iconColor.copy(alpha = pauseTransform), + topLeft = Offset(bodyCx + gap, bodyCy - barH / 2f), + size = androidx.compose.ui.geometry.Size(barW, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barRadius) + ) + } } } From 8dac52c2eb1151747178d22f1faf1d965ed5719b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 22:17:46 +0500 Subject: [PATCH 11/32] =?UTF-8?q?=D0=93=D0=BE=D0=BB=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F:=20lock=20UI=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20Teleg?= =?UTF-8?q?ram=20=E2=80=94=20=D0=B7=D0=B0=D0=BC=D0=BE=D0=BA,=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=83=D0=B7=D0=B0,=20slide-to-cancel,=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt | 2 +- .../java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 0483559..2ab85fa 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 @@ -1629,7 +1629,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { "avatar" -> AttachmentType.AVATAR.value "call" -> AttachmentType.CALL.value "voice" -> AttachmentType.VOICE.value - "video_circle", "videocircle", "video_note", "videonote", "round_video", "videoround", "video" -> + "video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> AttachmentType.VIDEO_CIRCLE.value else -> -1 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 43b5e74..cf0349c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -608,7 +608,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio "avatar" -> 3 "call" -> 4 "voice" -> 5 - "video_circle", "videocircle", "video_note", "videonote", "round_video", "videoround", "video" -> 6 + "video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6 else -> -1 } } From aa3cc766461232603cbcb68f924310790ded92ab Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 11 Apr 2026 23:57:10 +0500 Subject: [PATCH 12/32] =?UTF-8?q?fix:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D1=82=D1=8C=20SlideToCancel=201:1=20=D1=81?= =?UTF-8?q?=20Telegram=20=E2=80=94=20chevron=20arrow,=20=D0=BF=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=81=D0=B0=D1=86=D0=B8=D1=8F,=20entry=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram-exact SlideTextView: - Chevron arrow: Canvas-drawn path 4×5dp, stroke 1.6dp, round caps (не текст ◀) - Пульсация: ±6dp ТОЛЬКО при slideProgress > 0.8, скорость 12dp/s (3dp/250ms) - Frame-based animation через LaunchedEffect (не infiniteTransition) - Entry: slide in from right (translationX 20dp→0, 200ms) + fade in - Текст: "Slide to cancel" 15sp normal weight (было 13sp medium) - Цвет: #8E8E93 (Telegram key_chat_recordTime) - Translation: finger × 0.3 damping + pulse offset × slideProgress - Alpha: slideProgress × entryAlpha (плавно появляется и исчезает при свайпе) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 140 ++++++++++++++---- 1 file changed, 113 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 308a277..b219512 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 @@ -569,6 +569,19 @@ private fun LockIcon( } } +/** + * Telegram-exact SlideToCancel. + * + * 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) + */ @Composable private fun SlideToCancel( slideDx: Float, @@ -576,46 +589,119 @@ private fun SlideToCancel( isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val slideRatio = ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) - val textAlpha = if (slideRatio > 0.7f) 1f - ((slideRatio - 0.7f) / 0.3f) else 1f + // slideProgress: 1.0 = at rest, decreases as finger drags left toward cancel + val slideProgress = 1f - ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) - // Fix #5: Arrow pulsation ±6dp when slideRatio > 0.8, else ±3dp - val arrowAmplitude = if (slideRatio > 0.8f) -6f else -3f - val arrowPulse = rememberInfiniteTransition(label = "slide_cancel_arrow") - val arrowOffset by arrowPulse.animateFloat( - initialValue = 0f, - targetValue = arrowAmplitude, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 500, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "slide_cancel_arrow_offset" + val density = LocalDensity.current + + // 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 + + // Telegram: arrow oscillates ±6dp only when slideProgress > 0.8 + var xOffset by remember { mutableFloatStateOf(0f) } + var moveForward by remember { mutableStateOf(true) } + + LaunchedEffect(slideProgress > 0.8f) { + if (slideProgress <= 0.8f) { + xOffset = 0f + moveForward = true + return@LaunchedEffect + } + var lastTime = System.nanoTime() + while (true) { + delay(16) // ~60fps + 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 + } + } else { + xOffset -= step + 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.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.4f) - - // Fix #6: damping 0.3 (Telegram-style) Row( modifier = modifier .graphicsLayer { - translationX = slideDx * 0.3f - alpha = textAlpha + // Telegram: text follows finger × damping + entry slide + pulse offset + translationX = slideDx * 0.3f + entryTranslation + + xOffset * slideProgress + alpha = slideProgress * entryAlpha }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Text( - text = "◀", - color = textColor, - fontSize = 13.sp, - modifier = Modifier.graphicsLayer { translationX = arrowOffset } - ) + // Chevron arrow — Canvas-drawn, NOT text character + // Telegram: 4dp × 5dp chevron, stroke 1.6dp, round caps + Canvas( + modifier = Modifier + .size(width = 10.dp, height = 14.dp) + .graphicsLayer { + translationX = xOffset * slideProgress + } + ) { + val midY = size.height / 2f + val arrowW = 4.dp.toPx() + 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), + strokeWidth = strokeW, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + drawLine( + color = arrowColor, + start = Offset(startX, midY), + end = Offset(startX + arrowW, midY + arrowH), + strokeWidth = strokeW, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + Spacer(modifier = Modifier.width(4.dp)) + + // "Slide to cancel" text — Telegram: 15sp, normal weight Text( - text = "Slide to Cancel", + text = "Slide to cancel", color = textColor, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, maxLines = 1 ) } From afebbf6acb85eb6c7cfc7cf73ebd2c21a2a34882 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 00:08:33 +0500 Subject: [PATCH 13/32] =?UTF-8?q?fix:=20slide-to-cancel=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20LOCKED/PAUSED=20=E2=80=94=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=B2=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram: при sendButtonVisible=true gesture handler возвращает false, полностью блокируя горизонтальный свайп. Slide-to-cancel исчезает, вместо него кнопка Cancel. Изменения: - Gesture handler: только RECORDING обрабатывает slide (было RECORDING||LOCKED) - slideDx/slideDy не обновляются при LOCKED/PAUSED - При lock: slideDx=0, slideDy=0 — сбрасываем горизонтальное смещение - AnimatedContent уже переключает SlideToCancel→waveform при LOCKED Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index b219512..c31b1eb 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 @@ -2513,33 +2513,32 @@ fun MessageInputBar( val absDy = kotlin.math.abs(dy) if (absDx > maxAbsDx) maxAbsDx = absDx if (absDy > maxAbsDy) maxAbsDy = absDy - } else if ( - recordUiState == RecordUiState.RECORDING || - recordUiState == RecordUiState.LOCKED - ) { + } 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 (recordUiState == RecordUiState.RECORDING) { - 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 - setRecordUiState( - RecordUiState.LOCKED, - "slide-lock dy=${dy.toInt()}" - ) - } + 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 + setRecordUiState( + RecordUiState.LOCKED, + "slide-lock dy=${dy.toInt()}" + ) } } change.consume() From 7630aa6874b3d4e824c378eee74a851354558f41 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 00:15:09 +0500 Subject: [PATCH 14/32] =?UTF-8?q?fix:=20LOCKED=20UI=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=B2=20Telegram=20=E2=80=94=20CANCEL=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=E2=9C=95?= =?UTF-8?q?,=20=D0=B1=D0=B5=D0=B7=20blob=20=D0=BF=D1=80=D0=B8=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram LOCKED layout: [timer] [waveform] [CANCEL] [⏸] [Send] Изменения: - RecordLockedControls: убрана круглая ✕ кнопка delete - Вместо неё: текст "CANCEL" синим bold 15sp (как в Telegram) - Пауза иконка увеличена 12→14dp, фон 15% alpha - Blob анимация скрыта при LOCKED/PAUSED (Telegram: solid circle) - Spacing 8→12dp между CANCEL и паузой Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 89 ++++++++++--------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index c31b1eb..2037640 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 @@ -781,6 +781,17 @@ private fun VoiceWaveformBar( } } +/** + * Telegram-exact locked recording controls. + * + * Layout: [CANCEL text-button] [⏸/▶ circle button] + * + * - CANCEL = blue text (15sp bold, uppercase), clickable — cancels recording + * - ⏸ = small circle button (36dp), toggles pause/resume + * - No separate delete icon — CANCEL IS delete + * + * Reference: ChatActivityEnterView recordedAudioPanel + SlideTextView cancelToProgress + */ @Composable private fun RecordLockedControls( isPaused: Boolean, @@ -789,37 +800,31 @@ private fun RecordLockedControls( onTogglePause: () -> Unit, modifier: Modifier = Modifier ) { - val deleteBgColor = if (isDarkTheme) Color(0xFF444444) else Color(0xFFE0E0E0) - val deleteIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.8f) else Color(0xFF666666) - val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.3f) else Color(0xFF2D9CFF).copy(alpha = 0.2f) + val cancelColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) + val pauseBgColor = if (isDarkTheme) Color(0xFF69CCFF).copy(alpha = 0.15f) else Color(0xFF2D9CFF).copy(alpha = 0.1f) val pauseIconColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF) Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - // Delete button - Box( + // CANCEL text button — Telegram: blue bold uppercase + Text( + text = "CANCEL", + color = cancelColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(deleteBgColor) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null - ) { onDelete() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Delete recording", - tint = deleteIconColor, - modifier = Modifier.size(18.dp) - ) - } + ) { onDelete() } + .padding(horizontal = 4.dp, vertical = 8.dp) + ) - // Pause/Resume button + // Pause/Resume button — circle with icon Box( modifier = Modifier .size(36.dp) @@ -832,7 +837,8 @@ private fun RecordLockedControls( contentAlignment = Alignment.Center ) { if (isPaused) { - Canvas(modifier = Modifier.size(12.dp)) { + // Play triangle + Canvas(modifier = Modifier.size(14.dp)) { val path = Path().apply { moveTo(size.width * 0.2f, 0f) lineTo(size.width, size.height / 2f) @@ -842,19 +848,20 @@ private fun RecordLockedControls( drawPath(path, color = pauseIconColor) } } else { - Canvas(modifier = Modifier.size(12.dp)) { - val barW = size.width * 0.25f - val gap = size.width * 0.15f + // Pause bars + Canvas(modifier = Modifier.size(14.dp)) { + val barW = size.width * 0.22f + val gap = size.width * 0.14f drawRoundRect( color = pauseIconColor, - topLeft = Offset(size.width / 2f - gap - barW, 0f), - size = androidx.compose.ui.geometry.Size(barW, size.height), + topLeft = Offset(size.width / 2f - gap - barW, size.height * 0.1f), + size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f), cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f) ) drawRoundRect( color = pauseIconColor, - topLeft = Offset(size.width / 2f + gap, 0f), - size = androidx.compose.ui.geometry.Size(barW, size.height), + topLeft = Offset(size.width / 2f + gap, size.height * 0.1f), + size = androidx.compose.ui.geometry.Size(barW, size.height * 0.8f), cornerRadius = androidx.compose.ui.geometry.CornerRadius(barW / 3f) ) } @@ -2240,18 +2247,20 @@ fun MessageInputBar( } } - // Blob: 48dp base → 1.7x = ~82dp visual (matches Telegram circleRadius 41dp) - VoiceButtonBlob( - voiceLevel = voiceLevel, - isDarkTheme = isDarkTheme, - modifier = Modifier - .size(48.dp) - .graphicsLayer { - scaleX = 1.7f - scaleY = 1.7f - clip = false - } - ) + // Blob: only during RECORDING (Telegram hides waves when locked) + if (recordUiState == RecordUiState.RECORDING) { + VoiceButtonBlob( + voiceLevel = voiceLevel, + isDarkTheme = isDarkTheme, + modifier = Modifier + .size(48.dp) + .graphicsLayer { + scaleX = 1.7f + scaleY = 1.7f + clip = false + } + ) + } // Solid circle: 48dp layout, scaled to 82dp visual val sendScale by animateFloatAsState( From 5c02ff6fd331f1bb7dfa6db94ca85348335a151e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 00:33:26 +0500 Subject: [PATCH 15/32] =?UTF-8?q?fix:=20LOCKED=20panel=201:1=20=D1=81=20Te?= =?UTF-8?q?legram=20=E2=80=94=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=B4=D1=80=D1=83=D0=B3=D0=BE=D0=B9=20layout=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram при LOCKED: таймер и dot СКРЫТЫ, вместо них: - [Delete 44dp] — красная иконка удаления слева - [Waveform] — заполняет оставшееся место - Lock→Pause кнопка наверху (отдельный overlay) - Circle = Send (без blob) При RECORDING (без изменений): - [dot][timer] [◀ Slide to cancel] [Circle+Blob] Реализация: AnimatedContent crossfade между двумя полностью разными panel layouts. RecordLockedControls больше не используется в панели — delete в самой панели, pause в LockIcon overlay. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 170 ++++++++++-------- 1 file changed, 91 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 2037640..e74fdf7 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 @@ -2095,9 +2095,11 @@ fun MessageInputBar( label = "record_ui_shift" ) - // ── Telegram-style recording layout ── - // Telegram uses separate overlay layers (RecordCircle 194dp, ControlsView 250dp) - // We replicate with Box layers + graphicsLayer for overflow + // ── Telegram-exact recording layout ── + // RECORDING: [dot][timer] [◀ Slide to cancel] ... [Circle+Blob] + // LOCKED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Pause above + // PAUSED: [Delete] [Waveform 32dp] ... [Circle=Send] + Lock→Play above + // Timer and dot are HIDDEN in LOCKED/PAUSED (Telegram exact) Box( modifier = Modifier .fillMaxWidth() @@ -2108,93 +2110,103 @@ fun MessageInputBar( recordingInputRowY = coordinates.positionInWindow().y } ) { - // ── Layer 1: Panel bar (timer + center) ── - // Telegram: full-width bar, circle overlaps right edge - Row( + val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED + + // Crossfade between RECORDING panel and LOCKED panel + AnimatedContent( + targetState = isLockedOrPaused, modifier = Modifier .fillMaxWidth() - .height(40.dp) - .clip(RoundedCornerShape(20.dp)) - .background(recordingPanelColor) - .padding(start = 13.dp, end = 52.dp), // 52dp = half-circle overlap - verticalAlignment = Alignment.CenterVertically - ) { - // Blink dot + timer - Row( - modifier = Modifier - .graphicsLayer { - alpha = recordUiAlpha - translationX = with(density) { recordUiShift.toPx() } - }, - verticalAlignment = Alignment.CenterVertically - ) { - if (recordUiState == RecordUiState.PAUSED) { - Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { - Box(modifier = Modifier.size(10.dp).clip(CircleShape) - .background(if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D))) - } - } else { - RecordBlinkDot(isDarkTheme = isDarkTheme) - } - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = formatVoiceRecordTimer(voiceElapsedMs), - color = recordingTextColor, - fontSize = 15.sp, - fontWeight = FontWeight.Bold - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - // Center: SlideToCancel or Waveform+Controls - AnimatedContent( - targetState = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED, - modifier = Modifier - .weight(1f) - .graphicsLayer { - alpha = recordUiAlpha - translationX = with(density) { recordUiShift.toPx() } - }, - transitionSpec = { - fadeIn(tween(200)) togetherWith fadeOut(tween(200)) - }, - label = "record_center_content" - ) { isLockedOrPaused -> - if (isLockedOrPaused) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - VoiceWaveformBar( - waves = voiceWaves, - isDarkTheme = isDarkTheme, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.width(8.dp)) - RecordLockedControls( - isPaused = recordUiState == RecordUiState.PAUSED, - isDarkTheme = isDarkTheme, - onDelete = { + .height(44.dp) + .padding(end = 52.dp), // space for circle overlay + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(200)) + }, + label = "record_panel_mode" + ) { locked -> + if (locked) { + // ── LOCKED/PAUSED panel (Telegram: recordedAudioPanel) ── + // [Delete 44dp] [Waveform fills rest] + Row( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(22.dp)) + .background(recordingPanelColor), + verticalAlignment = Alignment.CenterVertically + ) { + // Delete button — Telegram: 44×44dp, Lottie trash icon + Box( + modifier = Modifier + .size(44.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState") stopVoiceRecording(send = false) }, - onTogglePause = { - inputJumpLog("tap PAUSE/RESUME mode=$recordMode state=$recordUiState") - if (recordUiState == RecordUiState.PAUSED) { - resumeVoiceRecording() - } else { - pauseVoiceRecording() - } - } + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Delete recording", + tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D), + modifier = Modifier.size(20.dp) ) } - } else { + + // Waveform — Telegram: 32dp height, fills remaining width + VoiceWaveformBar( + waves = voiceWaves, + isDarkTheme = isDarkTheme, + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + ) + } + } else { + // ── RECORDING panel ── + // [dot][timer] [◀ Slide to cancel] + Row( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(22.dp)) + .background(recordingPanelColor) + .padding(start = 13.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Blink dot + timer + Row( + modifier = Modifier + .graphicsLayer { + alpha = recordUiAlpha + translationX = with(density) { recordUiShift.toPx() } + }, + verticalAlignment = Alignment.CenterVertically + ) { + RecordBlinkDot(isDarkTheme = isDarkTheme) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = formatVoiceRecordTimer(voiceElapsedMs), + color = recordingTextColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Slide to cancel SlideToCancel( slideDx = slideDx, cancelThresholdPx = cancelDragThresholdPx, isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .weight(1f) + .graphicsLayer { + alpha = recordUiAlpha + translationX = with(density) { recordUiShift.toPx() } + } ) } } From b57e48fe20a00086d7ad19f3de9fd385f0e4f06f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 14:17:03 +0500 Subject: [PATCH 16/32] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=20=D0=93=D0=A1=20=E2=80=94=20race=20condition=20=D0=B2=20start?= =?UTF-8?q?VoiceRecording=20+=20=D1=83=D1=82=D0=B5=D1=87=D0=BA=D0=B0=20isV?= =?UTF-8?q?oiceRecordTransitioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause 1: startVoiceRecording() проверял только isVoiceRecording, но isVoiceRecording=true ставился через 192ms в scope.launch. При быстром двойном тапе два MediaRecorder создавались, первый терялся (утечка). Фикс: добавлен guard на isVoiceRecordTransitioning и voiceRecorder!=null. Root cause 2: isVoiceRecordTransitioning=true ставился перед scope.launch, но если launch крашился или composable disposed, transitioning навсегда оставался true — gesture guard блокировал все записи до перезапуска. Фикс: try/catch в launch + reset в DisposableEffect. Root cause 3: DisposableEffect проверял только isVoiceRecording, но не voiceRecorder!=null — если recorder создан но isVoiceRecording ещё false, recorder не освобождался при dispose. Фикс: проверка voiceRecorder!=null в dispose. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/chats/input/ChatDetailInput.kt | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index e74fdf7..1714a77 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 @@ -1180,7 +1180,7 @@ fun MessageInputBar( } fun startVoiceRecording() { - if (isVoiceRecording) return + if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return inputJumpLog( "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" @@ -1227,20 +1227,25 @@ fun MessageInputBar( ) scope.launch { - repeat(12) { - if (!isKeyboardVisible && !coordinator.isEmojiBoxVisible) return@repeat - delay(16) + try { + repeat(12) { + if (!isKeyboardVisible && !coordinator.isEmojiBoxVisible) return@repeat + delay(16) + } + isVoiceRecording = true + isVoiceRecordTransitioning = false + if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) { + setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started") + } + 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}") } - isVoiceRecording = true - isVoiceRecordTransitioning = false - if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) { - setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started") - } - inputJumpLog( - "startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + - "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + - "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx" - ) } } catch (_: Exception) { isVoiceRecordTransitioning = false @@ -1420,8 +1425,9 @@ fun MessageInputBar( DisposableEffect(Unit) { onDispose { pendingRecordAfterPermission = false + isVoiceRecordTransitioning = false resetGestureState() - if (isVoiceRecording) { + if (isVoiceRecording || voiceRecorder != null) { stopVoiceRecording(send = false) } else { setRecordUiState(RecordUiState.IDLE, "dispose") From 988896c080abc8ea7915cb0fcfb4669178030c81 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 14:44:46 +0500 Subject: [PATCH 17/32] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20TextSelectionHelper=20=E2=80=94=20core=20s?= =?UTF-8?q?tate,=20word=20snap,=20char=20offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt new file mode 100644 index 0000000..e798cf2 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -0,0 +1,132 @@ +package com.rosetta.messenger.ui.chats.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.text.Layout +import android.view.HapticFeedbackConstants +import android.view.View +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +data class LayoutInfo( + val layout: Layout, + val windowX: Int, + val windowY: Int, + val text: CharSequence +) + +class TextSelectionHelper { + + var selectionStart by mutableIntStateOf(-1) + private set + var selectionEnd by mutableIntStateOf(-1) + private set + var selectedMessageId by mutableStateOf(null) + private set + var layoutInfo by mutableStateOf(null) + private set + var isActive by mutableStateOf(false) + private set + var handleViewProgress by mutableFloatStateOf(0f) + private set + + val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart + + fun startSelection( + messageId: String, + info: LayoutInfo, + touchX: Int, + touchY: Int, + view: View? + ) { + val layout = info.layout + val localX = touchX - info.windowX + val localY = touchY - info.windowY + + val line = layout.getLineForVertical(localY) + val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line)) + val offset = layout.getOffsetForHorizontal(line, hx) + + val text = info.text + var start = offset + var end = offset + + while (start > 0 && Character.isLetterOrDigit(text[start - 1])) start-- + while (end < text.length && Character.isLetterOrDigit(text[end])) end++ + + if (start == end && end < text.length) end++ + + selectedMessageId = messageId + layoutInfo = info + selectionStart = start + selectionEnd = end + isActive = true + handleViewProgress = 1f + + view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + fun updateSelectionStart(charOffset: Int) { + if (!isActive) return + val text = layoutInfo?.text ?: return + var newStart = charOffset.coerceIn(0, text.length) + while (newStart > 0 && Character.isLetterOrDigit(text[newStart - 1])) newStart-- + if (newStart >= selectionEnd) return + selectionStart = newStart + } + + fun updateSelectionEnd(charOffset: Int) { + if (!isActive) return + val text = layoutInfo?.text ?: return + var newEnd = charOffset.coerceIn(0, text.length) + while (newEnd < text.length && Character.isLetterOrDigit(text[newEnd])) newEnd++ + if (newEnd <= selectionStart) return + selectionEnd = newEnd + } + + fun getCharOffsetFromCoords(x: Int, y: Int): Int { + val info = layoutInfo ?: return -1 + val localX = x - info.windowX + val localY = y - info.windowY + val layout = info.layout + val line = layout.getLineForVertical(localY.coerceIn(0, layout.height)) + val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line)) + return layout.getOffsetForHorizontal(line, hx) + } + + fun getSelectedText(): CharSequence? { + if (!isInSelectionMode) return null + val text = layoutInfo?.text ?: return null + val start = selectionStart.coerceIn(0, text.length) + val end = selectionEnd.coerceIn(start, text.length) + return text.subSequence(start, end) + } + + fun copySelectedText(context: Context) { + val selectedText = getSelectedText() ?: return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("selected_text", selectedText)) + Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() + clear() + } + + fun selectAll() { + val text = layoutInfo?.text ?: return + selectionStart = 0 + selectionEnd = text.length + } + + fun clear() { + selectionStart = -1 + selectionEnd = -1 + selectedMessageId = null + layoutInfo = null + isActive = false + handleViewProgress = 0f + } +} From 419761e34d3dd0258760e224c3e8fa9b8c17365c Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 15:03:35 +0500 Subject: [PATCH 18/32] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20TextSelectionOverlay=20=E2=80=94=20highlig?= =?UTF-8?q?ht,=20handles,=20drag=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index e798cf2..30f98a0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -7,11 +7,25 @@ import android.text.Layout import android.view.HapticFeedbackConstants import android.view.View import android.widget.Toast +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.rosetta.messenger.ui.onboarding.PrimaryBlue data class LayoutInfo( val layout: Layout, @@ -34,6 +48,17 @@ class TextSelectionHelper { private set var handleViewProgress by mutableFloatStateOf(0f) private set + var movingHandle by mutableStateOf(false) + private set + var movingHandleStart by mutableStateOf(false) + private set + private var movingOffsetX = 0f + private var movingOffsetY = 0f + + var startHandleX by mutableFloatStateOf(0f) + var startHandleY by mutableFloatStateOf(0f) + var endHandleX by mutableFloatStateOf(0f) + var endHandleY by mutableFloatStateOf(0f) val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart @@ -89,6 +114,26 @@ class TextSelectionHelper { selectionEnd = newEnd } + fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) { + movingHandle = true + movingHandleStart = isStart + movingOffsetX = if (isStart) startHandleX - touchX else endHandleX - touchX + movingOffsetY = if (isStart) startHandleY - touchY else endHandleY - touchY + } + + fun moveHandle(touchX: Float, touchY: Float) { + if (!movingHandle) return + val x = (touchX + movingOffsetX).toInt() + val y = (touchY + movingOffsetY).toInt() + val offset = getCharOffsetFromCoords(x, y) + if (offset < 0) return + if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset) + } + + fun endHandleDrag() { + movingHandle = false + } + fun getCharOffsetFromCoords(x: Int, y: Int): Int { val info = layoutInfo ?: return -1 val localX = x - info.windowX @@ -128,5 +173,154 @@ class TextSelectionHelper { layoutInfo = null isActive = false handleViewProgress = 0f + movingHandle = false } } + +private val HandleSize = 22.dp +private val HandleInset = 8.dp +private val HighlightCorner = 6.dp +private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f) +private val HandleColor = PrimaryBlue + +@Composable +fun TextSelectionOverlay( + helper: TextSelectionHelper, + modifier: Modifier = Modifier +) { + if (!helper.isInSelectionMode) return + + val density = LocalDensity.current + val handleSizePx = with(density) { HandleSize.toPx() } + val handleInsetPx = with(density) { HandleInset.toPx() } + val highlightCornerPx = with(density) { HighlightCorner.toPx() } + + Box(modifier = modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(helper.isActive) { + if (!helper.isActive) return@pointerInput + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: continue + + when { + change.pressed && !helper.movingHandle -> { + val x = change.position.x + val y = change.position.y + val startRect = Rect( + helper.startHandleX - handleSizePx / 2 - handleInsetPx, + helper.startHandleY - handleInsetPx, + helper.startHandleX + handleSizePx / 2 + handleInsetPx, + helper.startHandleY + handleSizePx + handleInsetPx + ) + val endRect = Rect( + helper.endHandleX - handleSizePx / 2 - handleInsetPx, + helper.endHandleY - handleInsetPx, + helper.endHandleX + handleSizePx / 2 + handleInsetPx, + helper.endHandleY + handleSizePx + handleInsetPx + ) + when { + startRect.contains(Offset(x, y)) -> { + helper.beginHandleDrag(isStart = true, x, y) + change.consume() + } + endRect.contains(Offset(x, y)) -> { + helper.beginHandleDrag(isStart = false, x, y) + change.consume() + } + else -> { + helper.clear() + } + } + } + change.pressed && helper.movingHandle -> { + helper.moveHandle(change.position.x, change.position.y) + change.consume() + } + !change.pressed && helper.movingHandle -> { + helper.endHandleDrag() + change.consume() + } + } + } + } + } + ) { + val info = helper.layoutInfo ?: return@Canvas + val layout = info.layout + val text = info.text + + val startOffset = helper.selectionStart.coerceIn(0, text.length) + val endOffset = helper.selectionEnd.coerceIn(0, text.length) + if (startOffset >= endOffset) return@Canvas + + val startLine = layout.getLineForOffset(startOffset) + val endLine = layout.getLineForOffset(endOffset) + + for (line in startLine..endLine) { + val lineTop = layout.getLineTop(line).toFloat() + info.windowY + val lineBottom = layout.getLineBottom(line).toFloat() + info.windowY + val left = if (line == startLine) { + layout.getPrimaryHorizontal(startOffset) + info.windowX + } else { + layout.getLineLeft(line) + info.windowX + } + val right = if (line == endLine) { + layout.getPrimaryHorizontal(endOffset) + info.windowX + } else { + layout.getLineRight(line) + info.windowX + } + drawRoundRect( + color = HighlightColor, + topLeft = Offset(left, lineTop), + size = Size(right - left, lineBottom - lineTop), + cornerRadius = CornerRadius(highlightCornerPx) + ) + } + + val startHx = layout.getPrimaryHorizontal(startOffset) + info.windowX + val startHy = layout.getLineBottom(startLine).toFloat() + info.windowY + val endHx = layout.getPrimaryHorizontal(endOffset) + info.windowX + val endHy = layout.getLineBottom(endLine).toFloat() + info.windowY + + helper.startHandleX = startHx + helper.startHandleY = startHy + helper.endHandleX = endHx + helper.endHandleY = endHy + + drawStartHandle(startHx, startHy, handleSizePx) + drawEndHandle(endHx, endHy, handleSizePx) + } + } +} + +private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) { + val half = size / 2f + drawCircle( + color = HandleColor, + radius = half, + center = Offset(x, y + half) + ) + drawRect( + color = HandleColor, + topLeft = Offset(x, y), + size = Size(half, half) + ) +} + +private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) { + val half = size / 2f + drawCircle( + color = HandleColor, + radius = half, + center = Offset(x, y + half) + ) + drawRect( + color = HandleColor, + topLeft = Offset(x - half, y), + size = Size(half, half) + ) +} From a10482b794d69a8cea2b6dc0e0188d665a9c2db8 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 15:05:25 +0500 Subject: [PATCH 19/32] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20onTextLongPress=20callback=20=D0=B8=20getL?= =?UTF-8?q?ayoutInfo()=20=D0=B2=20AppleEmojiTextView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/components/AppleEmojiEditText.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 988ee82..5643d3f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -558,6 +558,8 @@ fun AppleEmojiText( onClickableSpanPressStart: (() -> Unit)? = null, onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) + onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null, + onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null, minHeightMultiplier: Float = 1.5f ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f @@ -601,6 +603,8 @@ fun AppleEmojiText( enableMentionHighlight(enableMentions) setOnMentionClickListener(onMentionClick) setOnClickableSpanPressStartListener(onClickableSpanPressStart) + onTextLongPressCallback = onTextLongPress + onViewCreated?.invoke(this) // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. val canUseTextViewClick = !enableLinks setOnClickListener( @@ -634,6 +638,7 @@ fun AppleEmojiText( view.enableMentionHighlight(enableMentions) view.setOnMentionClickListener(onMentionClick) view.setOnClickableSpanPressStartListener(onClickableSpanPressStart) + view.onTextLongPressCallback = onTextLongPress // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. val canUseTextViewClick = !enableLinks view.setOnClickListener( @@ -695,13 +700,17 @@ class AppleEmojiTextView @JvmOverloads constructor( // 🔥 Long press callback для selection в MessageBubble var onLongClickCallback: (() -> Unit)? = null + var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> Unit)? = null private var downOnClickableSpan: Boolean = false private var suppressPerformClickOnce: Boolean = false // 🔥 GestureDetector для обработки long press поверх LinkMovementMethod private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onLongPress(e: MotionEvent) { - if (!downOnClickableSpan) { + if (downOnClickableSpan) return + if (onTextLongPressCallback != null) { + onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt()) + } else { onLongClickCallback?.invoke() } } @@ -822,6 +831,18 @@ class AppleEmojiTextView @JvmOverloads constructor( } } + fun getLayoutInfo(): com.rosetta.messenger.ui.chats.components.LayoutInfo? { + val l = layout ?: return null + val loc = IntArray(2) + getLocationInWindow(loc) + return com.rosetta.messenger.ui.chats.components.LayoutInfo( + layout = l, + windowX = loc[0] + totalPaddingLeft, + windowY = loc[1] + totalPaddingTop, + text = text ?: return null + ) + } + fun setTextWithEmojis(text: String) { val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD val processMentions = mentionsEnabled && !isLargeText From 7fcf1195e1022b277e1243b9c9c206a8c0cbc80b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 15:12:16 +0500 Subject: [PATCH 20/32] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20TextSelectio?= =?UTF-8?q?nHelper=20=D0=B2=20ChatDetailScreen=20=D0=B8=20MessageBubble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TextSelectionHelper инстанс в ChatDetailScreen - TextSelectionOverlay поверх LazyColumn - Clear selection при scroll и при message selection mode - onTextLongPress + onViewCreated проброшены через MessageBubble к AppleEmojiText Co-Authored-By: Claude Opus 4.6 (1M context) --- .../messenger/ui/chats/ChatDetailScreen.kt | 18 ++++++ .../chats/components/ChatDetailComponents.kt | 57 +++++++++++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 641f636..27b3702 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -394,6 +394,9 @@ fun ChatDetailScreen( var longPressSuppressedMessageId by remember { mutableStateOf(null) } var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) } + // 🔤 TEXT SELECTION - Telegram-style character-level selection + val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() } + // 💬 MESSAGE CONTEXT MENU STATE var contextMenuMessage by remember { mutableStateOf(null) } var showContextMenu by remember { mutableStateOf(false) } @@ -838,6 +841,7 @@ fun ChatDetailScreen( // иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается". val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit = { messageId, canSelect -> + textSelectionHelper.clear() if (canSelect && !selectedMessages.contains(messageId)) { selectedMessages = selectedMessages + messageId } @@ -886,6 +890,13 @@ fun ChatDetailScreen( } } + // 🔤 Сброс текстового выделения при скролле + LaunchedEffect(listState.isScrollInProgress) { + if (listState.isScrollInProgress && textSelectionHelper.isActive) { + textSelectionHelper.clear() + } + } + // 🔥 Display reply messages - получаем полную информацию о сообщениях для reply val displayReplyMessages = remember(replyMessages, messages) { @@ -3164,6 +3175,8 @@ fun ChatDetailScreen( MessageBubble( message = message, + textSelectionHelper = + textSelectionHelper, isDarkTheme = isDarkTheme, hasWallpaper = @@ -3644,6 +3657,11 @@ fun ChatDetailScreen( } } } + // 🔤 Text selection overlay + com.rosetta.messenger.ui.chats.components.TextSelectionOverlay( + helper = textSelectionHelper, + modifier = Modifier.fillMaxSize() + ) } } } 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 f3f6100..4957225 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 @@ -320,6 +320,7 @@ fun TypingIndicator( @Composable fun MessageBubble( message: ChatMessage, + textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null, isDarkTheme: Boolean, hasWallpaper: Boolean = false, isSystemSafeChat: Boolean = false, @@ -400,6 +401,7 @@ fun MessageBubble( if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне else Color(0xFF2196F3) // Стандартный Material Blue для входящих } + var textViewRef by remember { mutableStateOf(null) } val linksEnabled = !isSelectionMode val textClickHandler: (() -> Unit)? = onClick val mentionClickHandler: ((String) -> Unit)? = @@ -1066,7 +1068,20 @@ fun MessageBubble( onClick = textClickHandler, onLongClick = - onLongClick // 🔥 Long press для selection + onLongClick, // 🔥 Long press для selection + onViewCreated = { textViewRef = it }, + onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> + val info = textViewRef?.getLayoutInfo() + if (info != null) { + textSelectionHelper.startSelection( + messageId = message.id, + info = info, + touchX = touchX, + touchY = touchY, + view = textViewRef + ) + } + } else null ) }, timeContent = { @@ -1157,12 +1172,21 @@ fun MessageBubble( suppressBubbleTapFromSpan, onClick = textClickHandler, onLongClick = - onLongClick // 🔥 - // Long - // press - // для - // selection - ) + onLongClick, // 🔥 Long press для selection + onViewCreated = { textViewRef = it }, + onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> + val info = textViewRef?.getLayoutInfo() + if (info != null) { + textSelectionHelper.startSelection( + messageId = message.id, + info = info, + touchX = touchX, + touchY = touchY, + view = textViewRef + ) + } + } else null + ) }, timeContent = { Row( @@ -1261,11 +1285,20 @@ fun MessageBubble( suppressBubbleTapFromSpan, onClick = textClickHandler, onLongClick = - onLongClick // 🔥 - // Long - // press - // для - // selection + onLongClick, // 🔥 Long press для selection + onViewCreated = { textViewRef = it }, + onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> + val info = textViewRef?.getLayoutInfo() + if (info != null) { + textSelectionHelper.startSelection( + messageId = message.id, + info = info, + touchX = touchX, + touchY = touchY, + view = textViewRef + ) + } + } else null ) }, timeContent = { From e825a1ef30b1ef22a848be9a5aad614766f9cc09 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 15:14:30 +0500 Subject: [PATCH 21/32] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20floating=20toolbar=20(Copy/Select=20All)?= =?UTF-8?q?=20=D0=B8=20Magnifier=20(API=2028+)=20=D0=B4=D0=BB=D1=8F=20text?= =?UTF-8?q?=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index 30f98a0..0c88ce0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -8,24 +8,41 @@ import android.view.HapticFeedbackConstants import android.view.View import android.widget.Toast import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import kotlinx.coroutines.delay data class LayoutInfo( val layout: Layout, @@ -94,6 +111,7 @@ class TextSelectionHelper { handleViewProgress = 1f view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + showToolbar = false } fun updateSelectionStart(charOffset: Int) { @@ -132,6 +150,53 @@ class TextSelectionHelper { fun endHandleDrag() { movingHandle = false + showFloatingToolbar() + } + + var showToolbar by mutableStateOf(false) + private set + + fun showFloatingToolbar() { + if (isInSelectionMode && !movingHandle) { + showToolbar = true + } + } + + fun hideFloatingToolbar() { + showToolbar = false + } + + private var magnifier: android.widget.Magnifier? = null + private var magnifierView: View? = null + + fun setMagnifierView(view: View?) { + magnifierView = view + } + + fun showMagnifier(x: Float, y: Float) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return + val view = magnifierView ?: return + if (!movingHandle) return + if (magnifier == null) { + magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + android.widget.Magnifier.Builder(view) + .setSize(200, 80) + .setCornerRadius(16f) + .build() + } else { + android.widget.Magnifier(view) + } + } + val info = layoutInfo ?: return + val localX = (x - info.windowX).coerceIn(0f, view.width.toFloat()) + val localY = (y - info.windowY).coerceIn(0f, view.height.toFloat()) + magnifier?.show(localX, localY) + } + + fun hideMagnifier() { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return + magnifier?.dismiss() + magnifier = null } fun getCharOffsetFromCoords(x: Int, y: Int): Int { @@ -174,6 +239,8 @@ class TextSelectionHelper { isActive = false handleViewProgress = 0f movingHandle = false + showToolbar = false + hideMagnifier() } } @@ -183,6 +250,68 @@ private val HighlightCorner = 6.dp private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f) private val HandleColor = PrimaryBlue +@Composable +private fun FloatingToolbarPopup( + helper: TextSelectionHelper +) { + val context = LocalContext.current + + LaunchedEffect(helper.isActive, helper.movingHandle) { + if (helper.isActive && !helper.movingHandle && !helper.showToolbar) { + delay(200) + helper.showFloatingToolbar() + } + } + + if (!helper.showToolbar || !helper.isInSelectionMode) return + + val info = helper.layoutInfo ?: return + val layout = info.layout + val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length)) + val toolbarY = (layout.getLineTop(startLine) + info.windowY - 52).coerceAtLeast(0) + val toolbarX = ((helper.startHandleX + helper.endHandleX) / 2f - 80f).coerceAtLeast(0f) + + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(toolbarX.toInt(), toolbarY) + ) { + Row( + modifier = Modifier + .shadow(4.dp, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF333333)) + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Copy", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clickable { helper.copySelectedText(context) } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + val allSelected = helper.selectionStart <= 0 && + helper.selectionEnd >= info.text.length + if (!allSelected) { + Text( + text = "Select All", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clickable { + helper.selectAll() + helper.hideFloatingToolbar() + } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } +} + @Composable fun TextSelectionOverlay( helper: TextSelectionHelper, @@ -196,6 +325,7 @@ fun TextSelectionOverlay( val highlightCornerPx = with(density) { HighlightCorner.toPx() } Box(modifier = modifier.fillMaxSize()) { + FloatingToolbarPopup(helper = helper) Canvas( modifier = Modifier .fillMaxSize() @@ -225,10 +355,12 @@ fun TextSelectionOverlay( when { startRect.contains(Offset(x, y)) -> { helper.beginHandleDrag(isStart = true, x, y) + helper.hideFloatingToolbar() change.consume() } endRect.contains(Offset(x, y)) -> { helper.beginHandleDrag(isStart = false, x, y) + helper.hideFloatingToolbar() change.consume() } else -> { @@ -238,9 +370,11 @@ fun TextSelectionOverlay( } change.pressed && helper.movingHandle -> { helper.moveHandle(change.position.x, change.position.y) + helper.showMagnifier(change.position.x, change.position.y) change.consume() } !change.pressed && helper.movingHandle -> { + helper.hideMagnifier() helper.endHandleDrag() change.consume() } From 6ad24974e0ea570693077ce99c0be417dea691cc Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 15:15:50 +0500 Subject: [PATCH 22/32] =?UTF-8?q?feat:=20magnifier=20view=20setup=20+=20un?= =?UTF-8?q?it=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20Text?= =?UTF-8?q?SelectionHelper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setMagnifierView(view) в ChatDetailScreen через LaunchedEffect - 9 unit тестов: initial state, clear, getSelectedText, boundary checks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../messenger/ui/chats/ChatDetailScreen.kt | 1 + .../components/TextSelectionHelperTest.kt | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 27b3702..8e92cbe 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -396,6 +396,7 @@ fun ChatDetailScreen( // 🔤 TEXT SELECTION - Telegram-style character-level selection val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() } + LaunchedEffect(Unit) { textSelectionHelper.setMagnifierView(view) } // 💬 MESSAGE CONTEXT MENU STATE var contextMenuMessage by remember { mutableStateOf(null) } diff --git a/app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt b/app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt new file mode 100644 index 0000000..db746fc --- /dev/null +++ b/app/src/test/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelperTest.kt @@ -0,0 +1,77 @@ +package com.rosetta.messenger.ui.chats.components + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class TextSelectionHelperTest { + + private lateinit var helper: TextSelectionHelper + + @Before + fun setup() { + helper = TextSelectionHelper() + } + + @Test + fun `initial state is not active`() { + assertFalse(helper.isActive) + assertFalse(helper.isInSelectionMode) + assertEquals(-1, helper.selectionStart) + assertEquals(-1, helper.selectionEnd) + assertNull(helper.selectedMessageId) + } + + @Test + fun `clear resets all state`() { + helper.clear() + assertFalse(helper.isActive) + assertEquals(-1, helper.selectionStart) + assertEquals(-1, helper.selectionEnd) + assertNull(helper.selectedMessageId) + assertNull(helper.layoutInfo) + assertFalse(helper.showToolbar) + assertFalse(helper.movingHandle) + } + + @Test + fun `getSelectedText returns null when not active`() { + assertNull(helper.getSelectedText()) + } + + @Test + fun `updateSelectionEnd does not change when not active`() { + helper.updateSelectionEnd(5) + assertEquals(-1, helper.selectionEnd) + } + + @Test + fun `updateSelectionStart does not change when not active`() { + helper.updateSelectionStart(0) + assertEquals(-1, helper.selectionStart) + } + + @Test + fun `getCharOffsetFromCoords returns -1 when no layout`() { + assertEquals(-1, helper.getCharOffsetFromCoords(100, 100)) + } + + @Test + fun `selectAll does nothing when no layout`() { + helper.selectAll() + assertEquals(-1, helper.selectionStart) + assertEquals(-1, helper.selectionEnd) + } + + @Test + fun `moveHandle does nothing when not moving`() { + helper.moveHandle(100f, 100f) + assertFalse(helper.movingHandle) + } + + @Test + fun `endHandleDrag sets movingHandle to false`() { + helper.endHandleDrag() + assertFalse(helper.movingHandle) + } +} From 1ac3d93f74ce4f8ecdf27fd6c30da3e79a40b880 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 16:08:12 +0500 Subject: [PATCH 23/32] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=82=D1=8B=20text=20selection=20=E2=80=94?= =?UTF-8?q?=20window=E2=86=92overlay-local=20=D0=BA=D0=BE=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: overlay Canvas рисует в локальных координатах, но LayoutInfo возвращает позицию в window coordinates. Разница = position status bar, toolbar, и parent padding → highlight смещался вниз. Фикс: - onGloballyPositioned на overlay Box → знаем overlayWindowX/Y - Canvas: offsetX/Y = info.windowX - overlayWindowX (window→local) - getCharOffsetFromCoords: overlay-local → text-local через ту же delta - Handle positions теперь в overlay-local координатах → drag работает Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index 0c88ce0..5ab7aa9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -34,6 +35,8 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.input.pointer.pointerInput +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.text.font.FontWeight @@ -77,6 +80,10 @@ class TextSelectionHelper { var endHandleX by mutableFloatStateOf(0f) var endHandleY by mutableFloatStateOf(0f) + // Overlay position in window — set by TextSelectionOverlay + var overlayWindowX = 0f + var overlayWindowY = 0f + val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart fun startSelection( @@ -199,13 +206,14 @@ class TextSelectionHelper { magnifier = null } - fun getCharOffsetFromCoords(x: Int, y: Int): Int { + fun getCharOffsetFromCoords(overlayLocalX: Int, overlayLocalY: Int): Int { val info = layoutInfo ?: return -1 - val localX = x - info.windowX - val localY = y - info.windowY + // overlay-local → text-local: subtract text position relative to overlay + val textLocalX = overlayLocalX - (info.windowX - overlayWindowX) + val textLocalY = overlayLocalY - (info.windowY - overlayWindowY) val layout = info.layout - val line = layout.getLineForVertical(localY.coerceIn(0, layout.height)) - val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line)) + val line = layout.getLineForVertical(textLocalY.toInt().coerceIn(0, layout.height)) + val hx = textLocalX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line)) return layout.getOffsetForHorizontal(line, hx) } @@ -324,7 +332,15 @@ fun TextSelectionOverlay( val handleInsetPx = with(density) { HandleInset.toPx() } val highlightCornerPx = with(density) { HighlightCorner.toPx() } - Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coords -> + val pos = coords.positionInWindow() + helper.overlayWindowX = pos.x + helper.overlayWindowY = pos.y + } + ) { FloatingToolbarPopup(helper = helper) Canvas( modifier = Modifier @@ -387,6 +403,10 @@ fun TextSelectionOverlay( val layout = info.layout val text = info.text + // Convert window coords to overlay-local coords + val offsetX = info.windowX - helper.overlayWindowX + val offsetY = info.windowY - helper.overlayWindowY + val startOffset = helper.selectionStart.coerceIn(0, text.length) val endOffset = helper.selectionEnd.coerceIn(0, text.length) if (startOffset >= endOffset) return@Canvas @@ -395,17 +415,17 @@ fun TextSelectionOverlay( val endLine = layout.getLineForOffset(endOffset) for (line in startLine..endLine) { - val lineTop = layout.getLineTop(line).toFloat() + info.windowY - val lineBottom = layout.getLineBottom(line).toFloat() + info.windowY + val lineTop = layout.getLineTop(line).toFloat() + offsetY + val lineBottom = layout.getLineBottom(line).toFloat() + offsetY val left = if (line == startLine) { - layout.getPrimaryHorizontal(startOffset) + info.windowX + layout.getPrimaryHorizontal(startOffset) + offsetX } else { - layout.getLineLeft(line) + info.windowX + layout.getLineLeft(line) + offsetX } val right = if (line == endLine) { - layout.getPrimaryHorizontal(endOffset) + info.windowX + layout.getPrimaryHorizontal(endOffset) + offsetX } else { - layout.getLineRight(line) + info.windowX + layout.getLineRight(line) + offsetX } drawRoundRect( color = HighlightColor, @@ -415,10 +435,10 @@ fun TextSelectionOverlay( ) } - val startHx = layout.getPrimaryHorizontal(startOffset) + info.windowX - val startHy = layout.getLineBottom(startLine).toFloat() + info.windowY - val endHx = layout.getPrimaryHorizontal(endOffset) + info.windowX - val endHy = layout.getLineBottom(endLine).toFloat() + info.windowY + val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX + val startHy = layout.getLineBottom(startLine).toFloat() + offsetY + val endHx = layout.getPrimaryHorizontal(endOffset) + offsetX + val endHy = layout.getLineBottom(endLine).toFloat() + offsetY helper.startHandleX = startHx helper.startHandleY = startHy From 78925dd61d67e5f8557411e646c05261ee0e1092 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 16:12:29 +0500 Subject: [PATCH 24/32] =?UTF-8?q?fix:=20magnifier=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D1=8B=20+=20haptic=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Magnifier: - Конвертация overlay-local → view-local координаты для Magnifier.show() - Builder: 240×64px, cornerRadius 12, elevation 4, offset -80 (над текстом) Haptic: - TEXT_HANDLE_MOVE при каждом изменении selectionStart/selectionEnd - Как в Telegram: вибрация при перемещении handle по словам Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index 5ab7aa9..513faf4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -127,7 +127,9 @@ class TextSelectionHelper { var newStart = charOffset.coerceIn(0, text.length) while (newStart > 0 && Character.isLetterOrDigit(text[newStart - 1])) newStart-- if (newStart >= selectionEnd) return + val changed = newStart != selectionStart selectionStart = newStart + if (changed) hapticOnSelectionChange() } fun updateSelectionEnd(charOffset: Int) { @@ -136,7 +138,16 @@ class TextSelectionHelper { var newEnd = charOffset.coerceIn(0, text.length) while (newEnd < text.length && Character.isLetterOrDigit(text[newEnd])) newEnd++ if (newEnd <= selectionStart) return + val changed = newEnd != selectionEnd selectionEnd = newEnd + if (changed) hapticOnSelectionChange() + } + + private fun hapticOnSelectionChange() { + magnifierView?.performHapticFeedback( + HapticFeedbackConstants.TEXT_HANDLE_MOVE, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) } fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) { @@ -180,24 +191,30 @@ class TextSelectionHelper { magnifierView = view } - fun showMagnifier(x: Float, y: Float) { + fun showMagnifier(overlayLocalX: Float, overlayLocalY: Float) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return val view = magnifierView ?: return if (!movingHandle) return if (magnifier == null) { magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { android.widget.Magnifier.Builder(view) - .setSize(200, 80) - .setCornerRadius(16f) + .setSize(240, 64) + .setCornerRadius(12f) + .setElevation(4f) + .setDefaultSourceToMagnifierOffset(0, -80) .build() } else { + @Suppress("DEPRECATION") android.widget.Magnifier(view) } } val info = layoutInfo ?: return - val localX = (x - info.windowX).coerceIn(0f, view.width.toFloat()) - val localY = (y - info.windowY).coerceIn(0f, view.height.toFloat()) - magnifier?.show(localX, localY) + // Convert overlay-local → view-local (magnifierView coords) + val viewLoc = IntArray(2) + view.getLocationInWindow(viewLoc) + val sourceX = (overlayLocalX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat()) + val sourceY = (overlayLocalY + overlayWindowY - viewLoc[1]).coerceIn(0f, view.height.toFloat()) + magnifier?.show(sourceX, sourceY) } fun hideMagnifier() { From 9fe5f359239c18883e542ffab7430b35041a70a1 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 16:17:32 +0500 Subject: [PATCH 25/32] =?UTF-8?q?fix:=20=D0=BF=D0=BE=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20+=20magnifier=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20handle=20?= =?UTF-8?q?+=20haptic=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D0=B6=D0=B4=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: word snap при drag handle → нельзя выделить часть слова Стало: посимвольно при drag (word snap только при первом long press) Magnifier: показывается на позиции handle (текущий символ), а не на позиции пальца. По Y — центр строки текста. Haptic: TEXT_HANDLE_MOVE на каждый символ (не на каждое слово). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index 513faf4..8e7f8ba 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -124,8 +124,7 @@ class TextSelectionHelper { fun updateSelectionStart(charOffset: Int) { if (!isActive) return val text = layoutInfo?.text ?: return - var newStart = charOffset.coerceIn(0, text.length) - while (newStart > 0 && Character.isLetterOrDigit(text[newStart - 1])) newStart-- + val newStart = charOffset.coerceIn(0, text.length) if (newStart >= selectionEnd) return val changed = newStart != selectionStart selectionStart = newStart @@ -135,8 +134,7 @@ class TextSelectionHelper { fun updateSelectionEnd(charOffset: Int) { if (!isActive) return val text = layoutInfo?.text ?: return - var newEnd = charOffset.coerceIn(0, text.length) - while (newEnd < text.length && Character.isLetterOrDigit(text[newEnd])) newEnd++ + val newEnd = charOffset.coerceIn(0, text.length) if (newEnd <= selectionStart) return val changed = newEnd != selectionEnd selectionEnd = newEnd @@ -201,7 +199,7 @@ class TextSelectionHelper { .setSize(240, 64) .setCornerRadius(12f) .setElevation(4f) - .setDefaultSourceToMagnifierOffset(0, -80) + .setDefaultSourceToMagnifierOffset(0, -96) .build() } else { @Suppress("DEPRECATION") @@ -209,11 +207,21 @@ class TextSelectionHelper { } } val info = layoutInfo ?: return - // Convert overlay-local → view-local (magnifierView coords) + + // Magnifier should show at the HANDLE position (current char), not finger + // Use handle X for horizontal, and line center for vertical + val handleX = if (movingHandleStart) startHandleX else endHandleX + val handleY = if (movingHandleStart) startHandleY else endHandleY + val activeOffset = if (movingHandleStart) selectionStart else selectionEnd + val layout = info.layout + val line = layout.getLineForOffset(activeOffset.coerceIn(0, info.text.length)) + val lineCenter = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2f + + // Convert to view-local coordinates val viewLoc = IntArray(2) view.getLocationInWindow(viewLoc) - val sourceX = (overlayLocalX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat()) - val sourceY = (overlayLocalY + overlayWindowY - viewLoc[1]).coerceIn(0f, view.height.toFloat()) + val sourceX = (handleX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat()) + val sourceY = (lineCenter + info.windowY - viewLoc[1]).coerceIn(0f, view.height.toFloat()) magnifier?.show(sourceX, sourceY) } From ad08af7f0ccd98acb3282054cae0033b3ca935c5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 18:37:38 +0500 Subject: [PATCH 26/32] =?UTF-8?q?=D0=92=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=E2=80=94=20selection=20mode,=20handles,=20toolbar,=20magnifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 1 + .../chats/components/ChatDetailComponents.kt | 33 ++++++++++++-- .../chats/components/TextSelectionHelper.kt | 44 +++++++++++++++++-- .../ui/components/AppleEmojiEditText.kt | 28 +++++++++++- 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 8e92cbe..05beb49 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -3037,6 +3037,7 @@ fun ChatDetailScreen( else -> { LazyColumn( state = listState, + userScrollEnabled = !textSelectionHelper.movingHandle, modifier = Modifier.fillMaxSize() .nestedScroll( 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 4957225..5331dc8 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 @@ -402,6 +402,9 @@ fun MessageBubble( else Color(0xFF2196F3) // Стандартный Material Blue для входящих } var textViewRef by remember { mutableStateOf(null) } + val selectionDragEndHandler: (() -> Unit)? = if (textSelectionHelper != null) { + { textSelectionHelper.hideMagnifier(); textSelectionHelper.endHandleDrag() } + } else null val linksEnabled = !isSelectionMode val textClickHandler: (() -> Unit)? = onClick val mentionClickHandler: ((String) -> Unit)? = @@ -1070,7 +1073,7 @@ fun MessageBubble( onLongClick = onLongClick, // 🔥 Long press для selection onViewCreated = { textViewRef = it }, - onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> + onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY -> val info = textViewRef?.getLayoutInfo() if (info != null) { textSelectionHelper.startSelection( @@ -1081,7 +1084,18 @@ fun MessageBubble( view = textViewRef ) } - } else null + } else null, + onSelectionDrag = if (textSelectionHelper != null) { tx, ty -> + textSelectionHelper.moveHandle( + (tx - textSelectionHelper.overlayWindowX), + (ty - textSelectionHelper.overlayWindowY) + ) + textSelectionHelper.showMagnifier( + (tx - textSelectionHelper.overlayWindowX), + (ty - textSelectionHelper.overlayWindowY) + ) + } else null, + onSelectionDragEnd = selectionDragEndHandler ) }, timeContent = { @@ -1287,7 +1301,7 @@ fun MessageBubble( onLongClick = onLongClick, // 🔥 Long press для selection onViewCreated = { textViewRef = it }, - onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY -> + onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY -> val info = textViewRef?.getLayoutInfo() if (info != null) { textSelectionHelper.startSelection( @@ -1298,7 +1312,18 @@ fun MessageBubble( view = textViewRef ) } - } else null + } else null, + onSelectionDrag = if (textSelectionHelper != null) { tx, ty -> + textSelectionHelper.moveHandle( + (tx - textSelectionHelper.overlayWindowX), + (ty - textSelectionHelper.overlayWindowY) + ) + textSelectionHelper.showMagnifier( + (tx - textSelectionHelper.overlayWindowX), + (ty - textSelectionHelper.overlayWindowY) + ) + } else null, + onSelectionDragEnd = selectionDragEndHandler ) }, timeContent = { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index 8e7f8ba..c556b6f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -72,6 +72,11 @@ class TextSelectionHelper { private set var movingHandleStart by mutableStateOf(false) private set + // Telegram: isOneTouch = true during initial long-press drag (before finger lifts) + var isOneTouch by mutableStateOf(false) + private set + // Telegram: direction not determined yet — first drag decides start or end handle + private var movingDirectionSettling = false private var movingOffsetX = 0f private var movingOffsetY = 0f @@ -117,6 +122,13 @@ class TextSelectionHelper { isActive = true handleViewProgress = 1f + // Telegram: immediately enter drag mode — user can drag without lifting finger + movingHandle = true + movingDirectionSettling = true + isOneTouch = true + movingOffsetX = 0f + movingOffsetY = 0f + view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) showToolbar = false } @@ -161,11 +173,27 @@ class TextSelectionHelper { val y = (touchY + movingOffsetY).toInt() val offset = getCharOffsetFromCoords(x, y) if (offset < 0) return + + // Telegram: first drag determines which handle to move + if (movingDirectionSettling) { + if (offset < selectionStart) { + movingDirectionSettling = false + movingHandleStart = true + } else if (offset > selectionEnd) { + movingDirectionSettling = false + movingHandleStart = false + } else { + return // still within selected word, wait for more movement + } + } + if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset) } fun endHandleDrag() { movingHandle = false + movingDirectionSettling = false + isOneTouch = false showFloatingToolbar() } @@ -272,6 +300,8 @@ class TextSelectionHelper { isActive = false handleViewProgress = 0f movingHandle = false + movingDirectionSettling = false + isOneTouch = false showToolbar = false hideMagnifier() } @@ -301,12 +331,20 @@ private fun FloatingToolbarPopup( val info = helper.layoutInfo ?: return val layout = info.layout val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length)) - val toolbarY = (layout.getLineTop(startLine) + info.windowY - 52).coerceAtLeast(0) - val toolbarX = ((helper.startHandleX + helper.endHandleX) / 2f - 80f).coerceAtLeast(0f) + + // Toolbar position: centered above selection, in overlay-local coords + // startHandleX/endHandleX are already overlay-local + val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f + val selectionTopY = layout.getLineTop(startLine).toFloat() + + (info.windowY - helper.overlayWindowY) + val toolbarHeight = 40f // approximate toolbar height in px + val toolbarWidth = 160f // approximate toolbar width in px + val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f) + val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f) Popup( alignment = Alignment.TopStart, - offset = IntOffset(toolbarX.toInt(), toolbarY) + offset = IntOffset(toolbarX.toInt(), toolbarY.toInt()) ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 5643d3f..1306a59 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -559,6 +559,8 @@ fun AppleEmojiText( onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null, + onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null, + onSelectionDragEnd: (() -> Unit)? = null, onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null, minHeightMultiplier: Float = 1.5f ) { @@ -604,6 +606,8 @@ fun AppleEmojiText( setOnMentionClickListener(onMentionClick) setOnClickableSpanPressStartListener(onClickableSpanPressStart) onTextLongPressCallback = onTextLongPress + this.onSelectionDrag = onSelectionDrag + this.onSelectionDragEnd = onSelectionDragEnd onViewCreated?.invoke(this) // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. val canUseTextViewClick = !enableLinks @@ -639,6 +643,8 @@ fun AppleEmojiText( view.setOnMentionClickListener(onMentionClick) view.setOnClickableSpanPressStartListener(onClickableSpanPressStart) view.onTextLongPressCallback = onTextLongPress + view.onSelectionDrag = onSelectionDrag + view.onSelectionDragEnd = onSelectionDragEnd // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. val canUseTextViewClick = !enableLinks view.setOnClickListener( @@ -701,8 +707,12 @@ class AppleEmojiTextView @JvmOverloads constructor( // 🔥 Long press callback для selection в MessageBubble var onLongClickCallback: (() -> Unit)? = null var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> Unit)? = null + // Telegram flow: forward drag/up events after long press fires + var onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null + var onSelectionDragEnd: (() -> Unit)? = null private var downOnClickableSpan: Boolean = false private var suppressPerformClickOnce: Boolean = false + private var selectionDragActive: Boolean = false // 🔥 GestureDetector для обработки long press поверх LinkMovementMethod private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { @@ -710,6 +720,8 @@ class AppleEmojiTextView @JvmOverloads constructor( if (downOnClickableSpan) return if (onTextLongPressCallback != null) { onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt()) + selectionDragActive = true + parent?.requestDisallowInterceptTouchEvent(true) // block scroll during drag } else { onLongClickCallback?.invoke() } @@ -730,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor( MotionEvent.ACTION_DOWN -> { downOnClickableSpan = isTouchOnClickableSpan(event) suppressPerformClickOnce = downOnClickableSpan + selectionDragActive = false if (downOnClickableSpan) { clickableSpanPressStartCallback?.invoke() parent?.requestDisallowInterceptTouchEvent(true) } } + MotionEvent.ACTION_MOVE -> { + if (selectionDragActive) { + onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt()) + return true + } + } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + if (selectionDragActive) { + selectionDragActive = false + onSelectionDragEnd?.invoke() + downOnClickableSpan = false + parent?.requestDisallowInterceptTouchEvent(false) + return true + } downOnClickableSpan = false parent?.requestDisallowInterceptTouchEvent(false) } } - // Позволяем GestureDetector обработать событие (для long press) gestureDetector.onTouchEvent(event) - // Передаем событие дальше для обработки ссылок return super.dispatchTouchEvent(event) } From b1fc623f5e7c136c1638ce8d864a44acaac3ee84 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 23:05:55 +0500 Subject: [PATCH 27/32] =?UTF-8?q?=D0=92=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20+?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BA=D1=81=20ANR=20=D0=BF=D1=80=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=93=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/data/MessageRepository.kt | 1 - .../messenger/ui/chats/ChatDetailScreen.kt | 4 ++ .../chats/components/ChatDetailComponents.kt | 3 +- .../chats/components/TextSelectionHelper.kt | 62 +++++++++++++++---- .../ui/chats/input/ChatDetailInput.kt | 57 ++++++++++------- 5 files changed, 90 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 8c5fbad..18366d9 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -30,7 +30,6 @@ data class Message( val replyToMessageId: String? = null ) -/** UI модель диалога */ data class Dialog( val opponentKey: String, val opponentTitle: String, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 05beb49..0633e54 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -615,6 +615,8 @@ fun ChatDetailScreen( showImageViewer, showMediaPicker, showEmojiPicker, + textSelectionHelper.isActive, + textSelectionHelper.movingHandle, pendingCameraPhotoUri, pendingGalleryImages, showInAppCamera, @@ -624,6 +626,8 @@ fun ChatDetailScreen( showImageViewer || showMediaPicker || showEmojiPicker || + textSelectionHelper.isActive || + textSelectionHelper.movingHandle || pendingCameraPhotoUri != null || pendingGalleryImages.isNotEmpty() || showInAppCamera || 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 5331dc8..852c411 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 @@ -480,8 +480,9 @@ fun MessageBubble( Box( modifier = - Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) { + Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) { if (isSystemSafeChat) return@pointerInput + if (textSelectionHelper?.isActive == true) return@pointerInput // 🔥 Простой горизонтальный свайп для reply // Используем detectHorizontalDragGestures который лучше работает со // скроллом diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index c556b6f..cbb3430 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -85,6 +85,9 @@ class TextSelectionHelper { var endHandleX by mutableFloatStateOf(0f) var endHandleY by mutableFloatStateOf(0f) + // Back gesture callback — registered/unregistered by overlay + var backCallback: Any? = null + // Overlay position in window — set by TextSelectionOverlay var overlayWindowX = 0f var overlayWindowY = 0f @@ -330,17 +333,18 @@ private fun FloatingToolbarPopup( val info = helper.layoutInfo ?: return val layout = info.layout + val density = LocalDensity.current val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length)) - // Toolbar position: centered above selection, in overlay-local coords - // startHandleX/endHandleX are already overlay-local + // Toolbar positioned ABOVE selection top, in overlay-local coordinates val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f val selectionTopY = layout.getLineTop(startLine).toFloat() + (info.windowY - helper.overlayWindowY) - val toolbarHeight = 40f // approximate toolbar height in px - val toolbarWidth = 160f // approximate toolbar width in px - val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f) - val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f) + // Toolbar is ~48dp tall + 8dp gap above selection + val toolbarOffsetPx = with(density) { 56.dp.toPx() } + val toolbarWidthPx = with(density) { 200.dp.toPx() } + val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() }) + val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f) Popup( alignment = Alignment.TopStart, @@ -395,6 +399,36 @@ fun TextSelectionOverlay( val handleInsetPx = with(density) { HandleInset.toPx() } val highlightCornerPx = with(density) { HighlightCorner.toPx() } + // Block predictive back gesture completely during text selection. + // BackHandler alone doesn't prevent the swipe animation on Android 13+ + // with enableOnBackInvokedCallback=true. We must register an + // OnBackInvokedCallback at PRIORITY_OVERLAY to fully suppress it. + val activity = LocalContext.current as? android.app.Activity + LaunchedEffect(helper.isActive) { + if (android.os.Build.VERSION.SDK_INT >= 33 && activity != null) { + if (helper.isActive) { + val cb = android.window.OnBackInvokedCallback { /* consumed, do nothing */ } + activity.onBackInvokedDispatcher.registerOnBackInvokedCallback( + android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, cb + ) + helper.backCallback = cb + } else { + helper.backCallback?.let { cb -> + runCatching { + activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback( + cb as android.window.OnBackInvokedCallback + ) + } + } + helper.backCallback = null + } + } + } + // Fallback for Android < 13 + androidx.activity.compose.BackHandler(enabled = helper.isActive) { + // consumed — no navigation back while selecting + } + Box( modifier = modifier .fillMaxSize() @@ -477,18 +511,22 @@ fun TextSelectionOverlay( val startLine = layout.getLineForOffset(startOffset) val endLine = layout.getLineForOffset(endOffset) + // Padding around highlight for breathing room + val padH = 3.dp.toPx() + val padV = 2.dp.toPx() + for (line in startLine..endLine) { - val lineTop = layout.getLineTop(line).toFloat() + offsetY - val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV + val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV val left = if (line == startLine) { - layout.getPrimaryHorizontal(startOffset) + offsetX + layout.getPrimaryHorizontal(startOffset) + offsetX - padH } else { - layout.getLineLeft(line) + offsetX + layout.getLineLeft(line) + offsetX - padH } val right = if (line == endLine) { - layout.getPrimaryHorizontal(endOffset) + offsetX + layout.getPrimaryHorizontal(endOffset) + offsetX + padH } else { - layout.getLineRight(line) + offsetX + layout.getLineRight(line) + offsetX + padH } drawRoundRect( color = HighlightColor, 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 1714a77..859abab 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 @@ -1147,31 +1147,37 @@ fun MessageInputBar( voiceRecordStartedAtMs = 0L voiceElapsedMs = 0L voiceWaves = emptyList() - - var recordedOk = false - if (recorder != null) { - recordedOk = runCatching { - recorder.stop() - true - }.getOrDefault(false) - runCatching { recorder.reset() } - runCatching { recorder.release() } - } - - if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) { - val voiceHex = - runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("") - if (voiceHex.isNotBlank()) { - onSendVoiceMessage( - voiceHex, - durationSnapshot, - compressVoiceWaves(wavesSnapshot, 35) - ) - } - } - runCatching { outputFile?.delete() } resetGestureState() setRecordUiState(RecordUiState.IDLE, "stop(send=$send)") + + // Heavy I/O off main thread to prevent ANR + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + var recordedOk = false + if (recorder != null) { + recordedOk = runCatching { + recorder.stop() + true + }.getOrDefault(false) + runCatching { recorder.reset() } + runCatching { recorder.release() } + } + + if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) { + val voiceHex = + runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("") + if (voiceHex.isNotBlank()) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + onSendVoiceMessage( + voiceHex, + durationSnapshot, + compressVoiceWaves(wavesSnapshot, 35) + ) + } + } + } + runCatching { outputFile?.delete() } + } + inputJumpLog( "stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + @@ -1203,8 +1209,13 @@ fun MessageInputBar( recorder.setAudioEncodingBitRate(32_000) recorder.setAudioSamplingRate(48_000) recorder.setOutputFile(output.absolutePath) + recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit recorder.prepare() recorder.start() + recorder.setOnErrorListener { _, what, extra -> + inputJumpLog("MediaRecorder error what=$what extra=$extra") + stopVoiceRecording(send = false) + } voiceRecorder = recorder voiceOutputFile = output From cb920b490d5ba2bdbd5f412edfb305f7aa015a45 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 23:59:04 +0500 Subject: [PATCH 28/32] =?UTF-8?q?=D0=A1=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=80=94=20=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D1=83=D0=BB=D1=8F=D1=82=D0=BE=D1=80,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=B4=D0=B0,=20=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20+=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D0=B2=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 62 +++- .../com/rosetta/messenger/MainActivity.kt | 17 ++ .../messenger/data/PreferencesManager.kt | 16 ++ .../messenger/ui/settings/AppIconScreen.kt | 270 ++++++++++++++++++ .../messenger/ui/settings/AppearanceScreen.kt | 44 +++ .../main/res/drawable/ic_calc_background.xml | 4 + .../main/res/drawable/ic_calc_foreground.xml | 38 +++ .../main/res/drawable/ic_notes_background.xml | 4 + .../main/res/drawable/ic_notes_foreground.xml | 52 ++++ .../res/drawable/ic_weather_background.xml | 4 + .../res/drawable/ic_weather_foreground.xml | 32 +++ .../mipmap-anydpi-v26/ic_launcher_calc.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_notes.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_weather.xml | 5 + 14 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt create mode 100644 app/src/main/res/drawable/ic_calc_background.xml create mode 100644 app/src/main/res/drawable/ic_calc_foreground.xml create mode 100644 app/src/main/res/drawable/ic_notes_background.xml create mode 100644 app/src/main/res/drawable/ic_notes_foreground.xml create mode 100644 app/src/main/res/drawable/ic_weather_background.xml create mode 100644 app/src/main/res/drawable/ic_weather_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cffe881..2ad9cf4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,10 +47,7 @@ android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout" android:windowSoftInputMode="adjustResize" android:screenOrientation="portrait"> - - - - + @@ -65,6 +62,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = + context.dataStore.data.map { preferences -> + preferences[APP_ICON] ?: "default" + } + + suspend fun setAppIcon(value: String) { + context.dataStore.edit { preferences -> preferences[APP_ICON] = value } + } + // ═════════════════════════════════════════════════════════════ // 🔕 MUTED CHATS // ═════════════════════════════════════════════════════════════ 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 new file mode 100644 index 0000000..66402a9 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt @@ -0,0 +1,270 @@ +package com.rosetta.messenger.ui.settings + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import compose.icons.TablerIcons +import compose.icons.tablericons.ChevronLeft +import com.rosetta.messenger.R +import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +data class AppIconOption( + val id: String, + val label: String, + val subtitle: String, + val aliasName: String, + val iconRes: Int, + val previewBg: Color +) + +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)) +) + +@Composable +fun AppIconScreen( + isDarkTheme: Boolean, + onBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val prefs = remember { PreferencesManager(context) } + var currentIcon by remember { mutableStateOf("default") } + + LaunchedEffect(Unit) { + currentIcon = prefs.appIcon.first() + } + + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA) + + // Status bar + val view = androidx.compose.ui.platform.LocalView.current + if (!view.isInEditMode) { + DisposableEffect(isDarkTheme) { + val window = (view.context as android.app.Activity).window + val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) + val prev = insetsController.isAppearanceLightStatusBars + insetsController.isAppearanceLightStatusBars = !isDarkTheme + onDispose { insetsController.isAppearanceLightStatusBars = prev } + } + } + + BackHandler { onBack() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + ) { + // ═══════════════════════════════════════════════════════ + // TOP BAR — same style as SafetyScreen + // ═══════════════════════════════════════════════════════ + Surface( + modifier = Modifier.fillMaxWidth(), + color = backgroundColor + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = TablerIcons.ChevronLeft, + contentDescription = "Back", + tint = textColor + ) + } + Text( + text = "App Icon", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + + // ═══════════════════════════════════════════════════════ + // CONTENT + // ═══════════════════════════════════════════════════════ + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // Section header + Text( + text = "CHOOSE ICON", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor, + letterSpacing = 0.5.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Icon cards in grouped surface (Telegram style) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + color = surfaceColor + ) { + Column { + iconOptions.forEachIndexed { index, option -> + val isSelected = currentIcon == option.id + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (!isSelected) { + scope.launch { + changeAppIcon(context, prefs, option.id) + currentIcon = option.id + } + } + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon preview + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(12.dp)) + .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") + android.widget.ImageView.ScaleType.CENTER_CROP + else + android.widget.ImageView.ScaleType.FIT_CENTER + androidx.compose.ui.viewinterop.AndroidView( + factory = { ctx -> + android.widget.ImageView(ctx).apply { + setImageResource(option.iconRes) + this.scaleType = scaleType + } + }, + modifier = Modifier.size(iconSize) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + // Label + subtitle + Column(modifier = Modifier.weight(1f)) { + Text( + text = option.label, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + Text( + text = option.subtitle, + color = secondaryTextColor, + fontSize = 13.sp + ) + } + + // Checkmark + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = PrimaryBlue, + modifier = Modifier.size(22.dp) + ) + } + } + + // Divider between items (not after last) + if (index < iconOptions.lastIndex) { + Divider( + modifier = Modifier.padding(start = 82.dp), + thickness = 0.5.dp, + color = dividerColor + ) + } + } + } + } + + // Info text below + Text( + text = "The app icon and name on your home screen will change. Rosetta will continue to work normally. The launcher may take a moment to update.", + fontSize = 13.sp, + color = secondaryTextColor, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + lineHeight = 18.sp + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +private suspend fun changeAppIcon(context: Context, prefs: PreferencesManager, newIconId: String) { + val pm = context.packageManager + val packageName = context.packageName + + iconOptions.forEach { option -> + val component = ComponentName(packageName, "$packageName${option.aliasName}") + pm.setComponentEnabledSetting( + component, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + + val selected = iconOptions.first { it.id == newIconId } + val component = ComponentName(packageName, "$packageName${selected.aliasName}") + pm.setComponentEnabledSetting( + component, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + + prefs.setAppIcon(newIconId) + Toast.makeText(context, "Icon changed to ${selected.label}", Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index 0956fef..a216553 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -78,6 +78,7 @@ fun AppearanceScreen( onBack: () -> Unit, onBlurColorChange: (String) -> Unit, onToggleTheme: () -> Unit = {}, + onAppIconClick: () -> Unit = {}, accountPublicKey: String = "", accountName: String = "", avatarRepository: AvatarRepository? = null @@ -282,6 +283,49 @@ fun AppearanceScreen( lineHeight = 18.sp ) + Spacer(modifier = Modifier.height(24.dp)) + + // ═══════════════════════════════════════════════════════ + // APP ICON SECTION + // ═══════════════════════════════════════════════════════ + Text( + text = "APP ICON", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor, + letterSpacing = 0.5.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAppIconClick() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Change App Icon", + fontSize = 16.sp, + color = textColor + ) + Icon( + imageVector = TablerIcons.ChevronRight, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(20.dp) + ) + } + + Text( + text = "Disguise Rosetta as a calculator, weather app, or notes.", + fontSize = 13.sp, + color = secondaryTextColor, + modifier = Modifier.padding(horizontal = 16.dp), + lineHeight = 18.sp + ) + Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/app/src/main/res/drawable/ic_calc_background.xml b/app/src/main/res/drawable/ic_calc_background.xml new file mode 100644 index 0000000..697e55f --- /dev/null +++ b/app/src/main/res/drawable/ic_calc_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_calc_foreground.xml b/app/src/main/res/drawable/ic_calc_foreground.xml new file mode 100644 index 0000000..41c843b --- /dev/null +++ b/app/src/main/res/drawable/ic_calc_foreground.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_notes_background.xml b/app/src/main/res/drawable/ic_notes_background.xml new file mode 100644 index 0000000..fdeeb63 --- /dev/null +++ b/app/src/main/res/drawable/ic_notes_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notes_foreground.xml b/app/src/main/res/drawable/ic_notes_foreground.xml new file mode 100644 index 0000000..6acf37d --- /dev/null +++ b/app/src/main/res/drawable/ic_notes_foreground.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_weather_background.xml b/app/src/main/res/drawable/ic_weather_background.xml new file mode 100644 index 0000000..275abac --- /dev/null +++ b/app/src/main/res/drawable/ic_weather_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_weather_foreground.xml b/app/src/main/res/drawable/ic_weather_foreground.xml new file mode 100644 index 0000000..7bd9a2d --- /dev/null +++ b/app/src/main/res/drawable/ic_weather_foreground.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_calc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml new file mode 100644 index 0000000..87b115f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_notes.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml new file mode 100644 index 0000000..e7cd117 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_weather.xml @@ -0,0 +1,5 @@ + + + + + From ce7f913de70e9ebde2c44f84d89b2c76938fd4ae Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 14 Apr 2026 04:19:34 +0500 Subject: [PATCH 29/32] =?UTF-8?q?fix:=20=D0=91=D0=BE=D0=BB=D1=8C=D1=88?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rosetta/messenger/crypto/CryptoManager.kt | 16 +- .../messenger/ui/chats/ChatViewModel.kt | 45 +- .../messenger/ui/chats/ChatsListScreen.kt | 333 ++++++- .../messenger/ui/chats/SearchScreen.kt | 34 +- .../chats/components/AttachmentComponents.kt | 191 +++- .../chats/components/ChatDetailComponents.kt | 18 +- .../ui/chats/input/ChatDetailInput.kt | 846 ++++++++++++------ .../ui/components/SwipeBackContainer.kt | 78 +- .../rosetta/messenger/ui/qr/MyQrCodeScreen.kt | 39 +- .../messenger/ui/settings/AppIconScreen.kt | 17 +- .../messenger/ui/splash/SplashScreen.kt | 8 +- .../drawable-xxxhdpi/ic_calc_downloaded.png | Bin 0 -> 12147 bytes .../drawable-xxxhdpi/ic_notes_downloaded.png | Bin 0 -> 8893 bytes .../ic_weather_downloaded.png | Bin 0 -> 9733 bytes .../main/res/drawable/ic_calc_background.xml | 2 +- .../main/res/drawable/ic_calc_foreground.xml | 45 +- .../main/res/drawable/ic_notes_background.xml | 2 +- .../main/res/drawable/ic_notes_foreground.xml | 59 +- .../res/drawable/ic_weather_background.xml | 2 +- .../res/drawable/ic_weather_foreground.xml | 39 +- 20 files changed, 1241 insertions(+), 533 deletions(-) create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt index b7def35..b754626 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt @@ -45,6 +45,10 @@ object CryptoManager { // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной // расшифровке private const val DECRYPTION_CACHE_SIZE = 2000 + // Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key + // и хранения гигантских plaintext в памяти. + private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024 + private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024 private val decryptionCache = ConcurrentHashMap(DECRYPTION_CACHE_SIZE, 0.75f, 4) init { @@ -298,17 +302,21 @@ object CryptoManager { * 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений */ fun decryptWithPassword(encryptedData: String, password: String): String? { + val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS + val cacheKey = if (useCache) "$password:$encryptedData" else null + // 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap) - val cacheKey = "$password:$encryptedData" - decryptionCache[cacheKey]?.let { - return it + if (cacheKey != null) { + decryptionCache[cacheKey]?.let { + return it + } } return try { val result = decryptWithPasswordInternal(encryptedData, password) // 🚀 Сохраняем в кэш (lock-free) - if (result != null) { + if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) { // Ограничиваем размер кэша if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) { // Удаляем ~10% самых старых записей diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 2ab85fa..bf37bfe 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isOutgoing = fm.isOutgoing, publicKey = fm.senderPublicKey, senderName = fm.senderName, - attachments = fm.attachments + attachments = fm.attachments, + chachaKeyPlainHex = fm.chachaKeyPlain ) } _isForwardMode.value = true @@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { forwardedList.add(ReplyData( messageId = fwdMessageId, senderName = senderDisplayName, - text = fwdText, + text = resolveReplyPreviewText(fwdText, fwdAttachments), isFromMe = fwdIsFromMe, isForwarded = true, forwardedFromName = senderDisplayName, @@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ReplyData( messageId = realMessageId, senderName = resolvedSenderName, - text = replyText, + text = resolveReplyPreviewText(replyText, originalAttachments), isFromMe = isReplyFromMe, isForwarded = isForwarded, forwardedFromName = forwardFromDisplay, @@ -2501,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + private fun resolveReplyPreviewText( + text: String, + attachments: List + ): String { + if (text.isNotBlank()) return text + return when { + attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message" + attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message" + else -> text + } + } + /** * 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для * правильного отображения цитаты @@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { msg.senderPublicKey.trim().ifEmpty { if (msg.isOutgoing) sender else opponent } + val resolvedAttachments = + msg.attachments + .filter { it.type != AttachmentType.MESSAGES } ReplyMessage( messageId = msg.id, - text = msg.text, + text = resolveReplyPreviewText(msg.text, resolvedAttachments), timestamp = msg.timestamp.time, isOutgoing = msg.isOutgoing, publicKey = resolvedPublicKey, senderName = msg.senderName, - attachments = - msg.attachments - .filter { it.type != AttachmentType.MESSAGES }, + attachments = resolvedAttachments, chachaKeyPlainHex = msg.chachaKeyPlainHex ) } @@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { msg.senderPublicKey.trim().ifEmpty { if (msg.isOutgoing) sender else opponent } + val resolvedAttachments = + msg.attachments + .filter { it.type != AttachmentType.MESSAGES } ReplyMessage( messageId = msg.id, - text = msg.text, + text = resolveReplyPreviewText(msg.text, resolvedAttachments), timestamp = msg.timestamp.time, isOutgoing = msg.isOutgoing, publicKey = resolvedPublicKey, senderName = msg.senderName, - attachments = - msg.attachments - .filter { it.type != AttachmentType.MESSAGES } + attachments = resolvedAttachments, + chachaKeyPlainHex = msg.chachaKeyPlainHex ) } _isForwardMode.value = true @@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ReplyData( messageId = firstReply.messageId, senderName = firstReplySenderName, - text = firstReply.text, + text = resolveReplyPreviewText(firstReply.text, replyAttachments), isFromMe = firstReply.isOutgoing, isForwarded = isForward, forwardedFromName = @@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ReplyData( messageId = msg.messageId, senderName = senderDisplayName, - text = msg.text, + text = resolveReplyPreviewText(msg.text, resolvedAttachments), isFromMe = msg.isOutgoing, isForwarded = true, forwardedFromName = senderDisplayName, @@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isForwardToSend) { put("forwarded", true) put("senderName", msg.senderName) + if (msg.chachaKeyPlainHex.isNotEmpty()) { + put("chacha_key_plain", msg.chachaKeyPlainHex) + } } } replyJsonArray.put(replyJson) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 8c6e7d0..0a94abc 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -74,6 +74,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner +import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge @@ -222,6 +223,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): Bo return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) } } +private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean { + val active = playingDialogKey?.trim().orEmpty() + if (active.isEmpty()) return false + if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) { + return normalizeGroupDialogKey(dialogKey).equals( + normalizeGroupDialogKey(active), + ignoreCase = true + ) + } + return dialogKey.trim().equals(active, ignoreCase = true) +} + private fun shortPublicKey(value: String): String { val trimmed = value.trim() if (trimmed.length <= 12) return trimmed @@ -467,6 +480,12 @@ fun ChatsListScreen( // �🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState() + val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState() + val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() + val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() + val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() + val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() // Load dialogs when account is available LaunchedEffect(accountPublicKey, accountPrivateKey) { @@ -2130,6 +2149,50 @@ fun ChatsListScreen( callUiState.phase != CallPhase.INCOMING } val callBannerHeight = 40.dp + val showVoiceMiniPlayer = + remember( + showRequestsScreen, + showDownloadsScreen, + showCallsScreen, + playingVoiceAttachmentId + ) { + !showRequestsScreen && + !showDownloadsScreen && + !showCallsScreen && + !playingVoiceAttachmentId.isNullOrBlank() + } + val voiceBannerHeight = 36.dp + val stickyTopInset = + remember( + showStickyCallBanner, + showVoiceMiniPlayer + ) { + var topInset = 0.dp + if (showStickyCallBanner) { + topInset += callBannerHeight + } + if (showVoiceMiniPlayer) { + topInset += voiceBannerHeight + } + topInset + } + val voiceMiniPlayerTitle = + remember( + playingVoiceSenderLabel, + playingVoiceTimeLabel + ) { + val sender = + playingVoiceSenderLabel + .trim() + .ifBlank { + "Voice" + } + val time = + playingVoiceTimeLabel + .trim() + if (time.isBlank()) sender + else "$sender at $time" + } // 🔥 Берем dialogs из chatsState для // консистентности // 📌 Порядок по времени готовится в ViewModel. @@ -2332,9 +2395,7 @@ fun ChatsListScreen( Modifier.fillMaxSize() .padding( top = - if (showStickyCallBanner) - callBannerHeight - else 0.dp + stickyTopInset ) .then( if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll) @@ -2572,6 +2633,18 @@ fun ChatsListScreen( } } } + val isVoicePlaybackActive by + remember( + dialog.opponentKey, + playingVoiceDialogKey + ) { + derivedStateOf { + isVoicePlayingForDialog( + dialog.opponentKey, + playingVoiceDialogKey + ) + } + } val isSelectedDialog = selectedChatKeys .contains( @@ -2613,6 +2686,8 @@ fun ChatsListScreen( typingDisplayName, typingSenderPublicKey = typingSenderPublicKey, + isVoicePlaybackActive = + isVoicePlaybackActive, isBlocked = isBlocked, isSavedMessages = @@ -2746,14 +2821,41 @@ fun ChatsListScreen( } } } - if (showStickyCallBanner) { - CallTopBanner( - state = callUiState, - isSticky = true, - isDarkTheme = isDarkTheme, - avatarRepository = avatarRepository, - onOpenCall = onOpenCallOverlay - ) + if (showStickyCallBanner || showVoiceMiniPlayer) { + Column( + modifier = + Modifier.fillMaxWidth() + .align( + Alignment.TopCenter + ) + ) { + if (showStickyCallBanner) { + CallTopBanner( + state = callUiState, + isSticky = true, + isDarkTheme = isDarkTheme, + avatarRepository = avatarRepository, + onOpenCall = onOpenCallOverlay + ) + } + if (showVoiceMiniPlayer) { + VoiceTopMiniPlayer( + title = voiceMiniPlayerTitle, + isDarkTheme = isDarkTheme, + isPlaying = isVoicePlaybackRunning, + speed = voicePlaybackSpeed, + onTogglePlay = { + VoicePlaybackCoordinator.toggleCurrentPlayback() + }, + onCycleSpeed = { + VoicePlaybackCoordinator.cycleSpeed() + }, + onClose = { + VoicePlaybackCoordinator.stop() + } + ) + } + } } } } @@ -3722,6 +3824,7 @@ fun SwipeableDialogItem( isTyping: Boolean = false, typingDisplayName: String = "", typingSenderPublicKey: String = "", + isVoicePlaybackActive: Boolean = false, isBlocked: Boolean = false, isGroupChat: Boolean = false, isSavedMessages: Boolean = false, @@ -4125,6 +4228,7 @@ fun SwipeableDialogItem( isTyping = isTyping, typingDisplayName = typingDisplayName, typingSenderPublicKey = typingSenderPublicKey, + isVoicePlaybackActive = isVoicePlaybackActive, isPinned = isPinned, isBlocked = isBlocked, isMuted = isMuted, @@ -4144,6 +4248,7 @@ fun DialogItemContent( isTyping: Boolean = false, typingDisplayName: String = "", typingSenderPublicKey: String = "", + isVoicePlaybackActive: Boolean = false, isPinned: Boolean = false, isBlocked: Boolean = false, isMuted: Boolean = false, @@ -4278,12 +4383,12 @@ fun DialogItemContent( // Name and last message Column(modifier = Modifier.weight(1f)) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).heightIn(min = 22.dp), verticalAlignment = Alignment.CenterVertically ) { AppleEmojiText( @@ -4293,7 +4398,8 @@ fun DialogItemContent( color = textColor, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false + enableLinks = false, + minHeightMultiplier = 1f ) if (isGroupDialog) { Spacer(modifier = Modifier.width(5.dp)) @@ -4301,7 +4407,7 @@ fun DialogItemContent( imageVector = TablerIcons.Users, contentDescription = null, tint = secondaryTextColor.copy(alpha = 0.9f), - modifier = Modifier.size(15.dp) + modifier = Modifier.size(14.dp) ) } val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey) @@ -4310,7 +4416,7 @@ fun DialogItemContent( VerifiedBadge( verified = if (dialog.verified > 0) dialog.verified else 1, size = 16, - modifier = Modifier.offset(y = (-2).dp), + modifier = Modifier, isDarkTheme = isDarkTheme, badgeTint = PrimaryBlue ) @@ -4337,6 +4443,7 @@ fun DialogItemContent( } Row( + modifier = Modifier.heightIn(min = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { @@ -4467,7 +4574,7 @@ fun DialogItemContent( 0.6f ), modifier = - Modifier.size(14.dp) + Modifier.size(16.dp) ) Spacer( modifier = @@ -4487,9 +4594,11 @@ fun DialogItemContent( Text( text = formattedTime, fontSize = 13.sp, + lineHeight = 13.sp, color = if (visibleUnreadCount > 0) PrimaryBlue - else secondaryTextColor + else secondaryTextColor, + modifier = Modifier.align(Alignment.CenterVertically) ) } } @@ -4506,18 +4615,35 @@ fun DialogItemContent( modifier = Modifier.weight(1f).heightIn(min = 20.dp), contentAlignment = Alignment.CenterStart ) { + val subtitleMode = + remember( + isVoicePlaybackActive, + isTyping, + dialog.draftText + ) { + when { + isVoicePlaybackActive -> "voice" + isTyping -> "typing" + !dialog.draftText.isNullOrEmpty() -> "draft" + else -> "message" + } + } Crossfade( - targetState = isTyping, + targetState = subtitleMode, animationSpec = tween(150), label = "chatSubtitle" - ) { showTyping -> - if (showTyping) { + ) { mode -> + if (mode == "voice") { + VoicePlaybackIndicatorSmall( + isDarkTheme = isDarkTheme + ) + } else if (mode == "typing") { TypingIndicatorSmall( isDarkTheme = isDarkTheme, typingDisplayName = typingDisplayName, typingSenderPublicKey = typingSenderPublicKey ) - } else if (!dialog.draftText.isNullOrEmpty()) { + } else if (mode == "draft") { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "Draft: ", @@ -4527,7 +4653,7 @@ fun DialogItemContent( maxLines = 1 ) AppleEmojiText( - text = dialog.draftText, + text = dialog.draftText.orEmpty(), modifier = Modifier.weight(1f), fontSize = 14.sp, color = secondaryTextColor, @@ -4868,6 +4994,161 @@ fun TypingIndicatorSmall( } } +@Composable +private fun VoicePlaybackIndicatorSmall( + isDarkTheme: Boolean +) { + val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC) + val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator") + val levels = List(3) { index -> + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 900 + 0f at 0 + 1f at 280 + 0.2f at 580 + 0f at 900 + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(index * 130) + ), + label = "voiceBar$index" + ).value + } + + Row( + modifier = Modifier.heightIn(min = 18.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) { + val barWidth = 2.dp.toPx() + val gap = 2.dp.toPx() + val baseY = size.height + repeat(3) { index -> + val x = index * (barWidth + gap) + val progress = levels[index].coerceIn(0f, 1f) + val minH = 3.dp.toPx() + val maxH = 10.dp.toPx() + val height = minH + (maxH - minH) * progress + drawRoundRect( + color = accentColor.copy(alpha = 0.6f + progress * 0.4f), + topLeft = Offset(x, baseY - height), + size = androidx.compose.ui.geometry.Size(barWidth, height), + cornerRadius = + androidx.compose.ui.geometry.CornerRadius( + x = barWidth, + y = barWidth + ) + ) + } + } + Spacer(modifier = Modifier.width(5.dp)) + AppleEmojiText( + text = "Listening", + fontSize = 14.sp, + color = accentColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false, + minHeightMultiplier = 1f + ) + } +} + +private fun formatVoiceSpeedLabel(speed: Float): String { + val normalized = (speed * 10f).roundToInt() / 10f + return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) { + "${normalized.toInt()}x" + } else { + "${normalized}x" + } +} + +@Composable +private fun VoiceTopMiniPlayer( + title: String, + isDarkTheme: Boolean, + isPlaying: Boolean, + speed: Float, + onTogglePlay: () -> Unit, + onCycleSpeed: () -> Unit, + onClose: () -> Unit +) { + val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF) + val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC) + val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047) + val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A) + + Row( + modifier = + Modifier.fillMaxWidth() + .height(36.dp) + .background(containerColor) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onTogglePlay, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = + if (isPlaying) TablerIcons.PlayerPause + else TablerIcons.PlayerPlay, + contentDescription = if (isPlaying) "Pause voice" else "Play voice", + tint = accentColor, + modifier = Modifier.size(18.dp) + ) + } + + AppleEmojiText( + text = title, + fontSize = 14.sp, + color = textColor, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f), + enableLinks = false, + minHeightMultiplier = 1f + ) + + Box( + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .clickable { onCycleSpeed() } + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = formatVoiceSpeedLabel(speed), + color = accentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + IconButton( + onClick = onClose, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = TablerIcons.X, + contentDescription = "Close voice", + tint = secondaryColor, + modifier = Modifier.size(18.dp) + ) + } + } +} + @Composable private fun SwipeBackContainer( onBack: () -> Unit, @@ -5467,7 +5748,7 @@ fun DrawerMenuItemEnhanced( Text( text = text, fontSize = 15.sp, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = textColor, modifier = Modifier.weight(1f) ) @@ -5527,7 +5808,7 @@ fun DrawerMenuItemEnhanced( Text( text = text, fontSize = 15.sp, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Medium, color = textColor, modifier = Modifier.weight(1f) ) @@ -5561,7 +5842,7 @@ fun DrawerMenuItemEnhanced( fun DrawerDivider(isDarkTheme: Boolean) { Spacer(modifier = Modifier.height(8.dp)) Divider( - modifier = Modifier.padding(horizontal = 20.dp), + modifier = Modifier.fillMaxWidth(), color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC), thickness = 0.5.dp ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 5f1ae95..134e9c5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -573,23 +573,23 @@ private fun ChatsTabContent( } } - // ─── Recent header (always show with Clear All) ─── - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 14.dp, bottom = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Recent", - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = PrimaryBlue - ) - if (recentUsers.isNotEmpty()) { + // ─── Recent header (only when there are recents) ─── + if (recentUsers.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 14.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Recent", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = PrimaryBlue + ) Text( "Clear All", fontSize = 13.sp, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 55488ce..6c6ef99 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -7,6 +7,7 @@ import android.graphics.Matrix import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri +import android.os.Build import android.os.SystemClock import android.util.Base64 import android.util.LruCache @@ -91,6 +92,8 @@ import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.File import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Locale import kotlin.math.min import androidx.compose.ui.platform.LocalConfiguration import androidx.core.content.FileProvider @@ -153,25 +156,48 @@ private fun decodeVoicePayload(data: String): ByteArray? { return decodeHexPayload(data) ?: decodeBase64Payload(data) } -private object VoicePlaybackCoordinator { +object VoicePlaybackCoordinator { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val speedSteps = listOf(1f, 1.5f, 2f) private var player: MediaPlayer? = null private var currentAttachmentId: String? = null private var progressJob: Job? = null private val _playingAttachmentId = MutableStateFlow(null) val playingAttachmentId: StateFlow = _playingAttachmentId.asStateFlow() + private val _playingDialogKey = MutableStateFlow(null) + val playingDialogKey: StateFlow = _playingDialogKey.asStateFlow() private val _positionMs = MutableStateFlow(0) val positionMs: StateFlow = _positionMs.asStateFlow() private val _durationMs = MutableStateFlow(0) val durationMs: StateFlow = _durationMs.asStateFlow() + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow = _isPlaying.asStateFlow() + private val _playbackSpeed = MutableStateFlow(1f) + val playbackSpeed: StateFlow = _playbackSpeed.asStateFlow() + private val _playingSenderLabel = MutableStateFlow("") + val playingSenderLabel: StateFlow = _playingSenderLabel.asStateFlow() + private val _playingTimeLabel = MutableStateFlow("") + val playingTimeLabel: StateFlow = _playingTimeLabel.asStateFlow() - fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) { + fun toggle( + attachmentId: String, + sourceFile: File, + dialogKey: String = "", + senderLabel: String = "", + playedAtLabel: String = "", + onError: (String) -> Unit = {} + ) { if (!sourceFile.exists()) { onError("Voice file is missing") return } - if (currentAttachmentId == attachmentId && player?.isPlaying == true) { - stop() + + if (currentAttachmentId == attachmentId && player != null) { + if (_isPlaying.value) { + pause() + } else { + resume(onError = onError) + } return } @@ -187,22 +213,18 @@ private object VoicePlaybackCoordinator { mediaPlayer.setDataSource(sourceFile.absolutePath) mediaPlayer.setOnCompletionListener { stop() } mediaPlayer.prepare() + applyPlaybackSpeed(mediaPlayer) mediaPlayer.start() player = mediaPlayer currentAttachmentId = attachmentId _playingAttachmentId.value = attachmentId + _playingDialogKey.value = dialogKey.trim().ifBlank { null } + _playingSenderLabel.value = senderLabel.trim() + _playingTimeLabel.value = playedAtLabel.trim() _durationMs.value = mediaPlayer.duration.coerceAtLeast(0) _positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0) - progressJob?.cancel() - progressJob = - scope.launch { - while (isActive && currentAttachmentId == attachmentId) { - val active = player - if (active == null || !active.isPlaying) break - _positionMs.value = active.currentPosition.coerceAtLeast(0) - delay(120) - } - } + _isPlaying.value = true + startProgressUpdates(attachmentId) } catch (e: Exception) { runCatching { mediaPlayer.release() } stop() @@ -210,6 +232,80 @@ private object VoicePlaybackCoordinator { } } + fun toggleCurrentPlayback(onError: (String) -> Unit = {}) { + if (player == null || currentAttachmentId.isNullOrBlank()) return + if (_isPlaying.value) { + pause() + } else { + resume(onError = onError) + } + } + + fun pause() { + val active = player ?: return + runCatching { + if (active.isPlaying) active.pause() + } + _isPlaying.value = false + progressJob?.cancel() + progressJob = null + _positionMs.value = active.currentPosition.coerceAtLeast(0) + } + + fun resume(onError: (String) -> Unit = {}) { + val active = player ?: return + val attachmentId = currentAttachmentId + if (attachmentId.isNullOrBlank()) return + try { + applyPlaybackSpeed(active) + active.start() + _durationMs.value = active.duration.coerceAtLeast(0) + _positionMs.value = active.currentPosition.coerceAtLeast(0) + _isPlaying.value = true + startProgressUpdates(attachmentId) + } catch (e: Exception) { + stop() + onError(e.message ?: "Playback failed") + } + } + + fun cycleSpeed() { + val current = _playbackSpeed.value + val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f } + val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size] + setPlaybackSpeed(next) + } + + private fun setPlaybackSpeed(speed: Float) { + val normalized = + speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() + _playbackSpeed.value = normalized + player?.let { applyPlaybackSpeed(it) } + } + + private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return + runCatching { + val current = mediaPlayer.playbackParams + mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value) + } + } + + private fun startProgressUpdates(attachmentId: String) { + progressJob?.cancel() + progressJob = + scope.launch { + while (isActive && currentAttachmentId == attachmentId) { + val active = player ?: break + _positionMs.value = active.currentPosition.coerceAtLeast(0) + _durationMs.value = active.duration.coerceAtLeast(0) + if (!active.isPlaying) break + delay(120) + } + _isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId + } + } + fun stop() { val active = player player = null @@ -217,8 +313,12 @@ private object VoicePlaybackCoordinator { progressJob?.cancel() progressJob = null _playingAttachmentId.value = null + _playingDialogKey.value = null + _playingSenderLabel.value = "" + _playingTimeLabel.value = "" _positionMs.value = 0 _durationMs.value = 0 + _isPlaying.value = false if (active != null) { runCatching { if (active.isPlaying) active.stop() @@ -593,6 +693,7 @@ fun MessageAttachments( isOutgoing: Boolean, isDarkTheme: Boolean, senderPublicKey: String, + senderDisplayName: String = "", dialogPublicKey: String = "", isGroupChat: Boolean = false, timestamp: java.util.Date, @@ -683,6 +784,8 @@ fun MessageAttachments( chachaKeyPlainHex = chachaKeyPlainHex, privateKey = privateKey, senderPublicKey = senderPublicKey, + senderDisplayName = senderDisplayName, + dialogPublicKey = dialogPublicKey, isOutgoing = isOutgoing, isDarkTheme = isDarkTheme, timestamp = timestamp, @@ -2036,6 +2139,8 @@ private fun VoiceAttachment( chachaKeyPlainHex: String, privateKey: String, senderPublicKey: String, + senderDisplayName: String, + dialogPublicKey: String, isOutgoing: Boolean, isDarkTheme: Boolean, timestamp: java.util.Date, @@ -2043,10 +2148,12 @@ private fun VoiceAttachment( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState() val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState() - val isPlaying = playingAttachmentId == attachment.id + val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState() + val isActiveTrack = activeAttachmentId == attachment.id + val isPlaying = isActiveTrack && playbackIsPlaying val (previewDurationSecRaw, previewWavesRaw) = remember(attachment.preview) { parseVoicePreview(attachment.preview) } @@ -2078,21 +2185,37 @@ private fun VoiceAttachment( val effectiveDurationSec = remember(isPlaying, playbackDurationMs, previewDurationSec) { val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0) - if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec + if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec } val progress = - if (isPlaying && playbackDurationMs > 0) { + if (isActiveTrack && playbackDurationMs > 0) { (playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f) } else { 0f } val timeText = - if (isPlaying && playbackDurationMs > 0) { + if (isActiveTrack && playbackDurationMs > 0) { val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) - "-${formatVoiceDuration(leftSec)}" + formatVoiceDuration(leftSec) } else { formatVoiceDuration(effectiveDurationSec) } + val playbackSenderLabel = + remember(isOutgoing, senderDisplayName) { + val senderName = senderDisplayName.trim() + when { + isOutgoing -> "You" + senderName.isNotBlank() -> senderName + else -> "Voice" + } + } + val playbackTimeLabel = + remember(timestamp.time) { + runCatching { + SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp) + } + .getOrDefault("") + } LaunchedEffect(payload, attachment.id) { if (payload.isBlank()) return@LaunchedEffect @@ -2112,14 +2235,6 @@ private fun VoiceAttachment( } } - DisposableEffect(attachment.id) { - onDispose { - if (playingAttachmentId == attachment.id) { - VoicePlaybackCoordinator.stop() - } - } - } - val triggerDownload: () -> Unit = download@{ if (attachment.transportTag.isBlank()) { downloadStatus = DownloadStatus.ERROR @@ -2172,9 +2287,15 @@ private fun VoiceAttachment( if (file == null || !file.exists()) { if (payload.isNotBlank()) { val prepared = ensureVoiceAudioFile(context, attachment.id, payload) - if (prepared != null) { - audioFilePath = prepared.absolutePath - VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message -> + if (prepared != null) { + audioFilePath = prepared.absolutePath + VoicePlaybackCoordinator.toggle( + attachmentId = attachment.id, + sourceFile = prepared, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> downloadStatus = DownloadStatus.ERROR errorText = message } @@ -2186,7 +2307,13 @@ private fun VoiceAttachment( triggerDownload() } } else { - VoicePlaybackCoordinator.toggle(attachment.id, file) { message -> + VoicePlaybackCoordinator.toggle( + attachmentId = attachment.id, + sourceFile = file, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> downloadStatus = DownloadStatus.ERROR errorText = message } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 852c411..02f634c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -355,6 +355,16 @@ fun MessageBubble( onGroupInviteOpen: (SearchUser) -> Unit = {}, contextMenuContent: @Composable () -> Unit = {} ) { + val isTextSelectionOnThisMessage = + remember( + textSelectionHelper?.isInSelectionMode, + textSelectionHelper?.selectedMessageId, + message.id + ) { + textSelectionHelper?.isInSelectionMode == true && + textSelectionHelper.selectedMessageId == message.id + } + // Swipe-to-reply state val hapticFeedback = LocalHapticFeedback.current var swipeOffset by remember { mutableStateOf(0f) } @@ -374,7 +384,7 @@ fun MessageBubble( // Selection animations val selectionAlpha by animateFloatAsState( - targetValue = if (isSelected) 0.85f else 1f, + targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f, animationSpec = tween(150), label = "selectionAlpha" ) @@ -558,7 +568,8 @@ fun MessageBubble( val selectionBackgroundColor by animateColorAsState( targetValue = - if (isSelected) PrimaryBlue.copy(alpha = 0.15f) + if (isSelected && !isTextSelectionOnThisMessage) + PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent, animationSpec = tween(200), label = "selectionBg" @@ -1004,6 +1015,7 @@ fun MessageBubble( isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, senderPublicKey = senderPublicKey, + senderDisplayName = senderName, dialogPublicKey = dialogPublicKey, isGroupChat = isGroupChat, timestamp = message.timestamp, @@ -2381,6 +2393,8 @@ fun ReplyBubble( ) } else if (!hasImage) { val displayText = when { + replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message" + replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message" replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call" else -> "..." diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 859abab..cad9e45 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer @@ -48,6 +49,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.draw.shadow @@ -78,10 +80,12 @@ import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.ChatViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import java.io.File import java.util.Locale import java.util.UUID @@ -89,6 +93,11 @@ import kotlin.math.PI import kotlin.math.sin private val EaseOutQuint = CubicBezierEasing(0.23f, 1f, 0.32f, 1f) +private const val INPUT_JUMP_LOG_ENABLED = false + +private fun lerpFloat(start: Float, end: Float, fraction: Float): Float { + return start + (end - start) * fraction +} private fun truncateEmojiSafe(text: String, maxLen: Int): String { if (text.length <= maxLen) return text @@ -209,6 +218,139 @@ private fun RecordBlinkDot( } } +@Composable +private fun TelegramVoiceDeleteIndicator( + cancelProgress: Float, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val progress = cancelProgress.coerceIn(0f, 1f) + val appear = FastOutSlowInEasing.transform(progress) + val openPhase = FastOutSlowInEasing.transform((progress / 0.45f).coerceIn(0f, 1f)) + val closePhase = FastOutSlowInEasing.transform(((progress - 0.55f) / 0.45f).coerceIn(0f, 1f)) + val lidAngle = -26f * openPhase * (1f - closePhase) + val dotFlight = FastOutSlowInEasing.transform((progress / 0.82f).coerceIn(0f, 1f)) + + val dangerColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) + val dotStartX = with(density) { (-8).dp.toPx() } + val dotEndX = with(density) { 0.dp.toPx() } + val dotEndY = with(density) { 6.dp.toPx() } + val dotX = lerpFloat(dotStartX, dotEndX, dotFlight) + val dotY = dotEndY * dotFlight * dotFlight + val dotScale = (1f - 0.72f * dotFlight).coerceAtLeast(0f) + val dotAlpha = (1f - dotFlight).coerceIn(0f, 1f) + + Box( + modifier = modifier.size(28.dp), + contentAlignment = Alignment.Center + ) { + RecordBlinkDot( + isDarkTheme = isDarkTheme, + modifier = Modifier.graphicsLayer { + alpha = 1f - appear + scaleX = 1f - 0.14f * appear + scaleY = 1f - 0.14f * appear + } + ) + + Canvas( + modifier = Modifier + .matchParentSize() + .graphicsLayer { + alpha = appear + scaleX = 0.84f + 0.16f * appear + scaleY = 0.84f + 0.16f * appear + } + ) { + val stroke = 1.7.dp.toPx() + val cx = size.width / 2f + val bodyW = size.width * 0.36f + val bodyH = size.height * 0.34f + val bodyLeft = cx - bodyW / 2f + val bodyTop = size.height * 0.45f + val bodyRadius = bodyW * 0.16f + val bodyRight = bodyLeft + bodyW + + drawRoundRect( + color = dangerColor, + topLeft = Offset(bodyLeft, bodyTop), + size = androidx.compose.ui.geometry.Size(bodyW, bodyH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius), + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round, + join = androidx.compose.ui.graphics.StrokeJoin.Round + ) + ) + + val slatYStart = bodyTop + bodyH * 0.18f + val slatYEnd = bodyTop + bodyH * 0.82f + drawLine( + color = dangerColor, + start = Offset(cx - bodyW * 0.18f, slatYStart), + end = Offset(cx - bodyW * 0.18f, slatYEnd), + strokeWidth = stroke * 0.85f, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + drawLine( + color = dangerColor, + start = Offset(cx + bodyW * 0.18f, slatYStart), + end = Offset(cx + bodyW * 0.18f, slatYEnd), + strokeWidth = stroke * 0.85f, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + val rimY = bodyTop - 2.4.dp.toPx() + drawLine( + color = dangerColor, + start = Offset(bodyLeft - bodyW * 0.09f, rimY), + end = Offset(bodyRight + bodyW * 0.09f, rimY), + strokeWidth = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + + val lidY = rimY - 1.4.dp.toPx() + val lidLeft = bodyLeft - bodyW * 0.05f + val lidRight = bodyRight + bodyW * 0.05f + val lidPivot = Offset(bodyLeft + bodyW * 0.22f, lidY) + rotate( + degrees = lidAngle, + pivot = lidPivot + ) { + drawLine( + color = dangerColor, + start = Offset(lidLeft, lidY), + end = Offset(lidRight, lidY), + strokeWidth = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + drawLine( + color = dangerColor, + start = Offset(cx - bodyW * 0.1f, lidY - 2.dp.toPx()), + end = Offset(cx + bodyW * 0.1f, lidY - 2.dp.toPx()), + strokeWidth = stroke, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + + Box( + modifier = Modifier + .size(10.dp) + .graphicsLayer { + translationX = dotX + translationY = dotY + alpha = if (progress > 0f) dotAlpha else 0f + scaleX = dotScale + scaleY = dotScale + } + .clip(CircleShape) + .background(dangerColor) + ) + } +} + @Composable private fun VoiceMovingBlob( voiceLevel: Float, @@ -570,141 +712,110 @@ private fun LockIcon( } /** - * Telegram-exact SlideToCancel. + * iOS parity slide-to-cancel transform. * - * Layout: [chevron arrow] "Slide to cancel" - * - * - Arrow is a Canvas-drawn chevron (4×5dp, stroke 1.6dp, round caps) - * - Arrow oscillates ±6dp ONLY when slideProgress > 0.8 at 12dp/s - * - Text alpha = slideProgress (fades in with drag, 0 = invisible at rest) - * - Translation follows finger × 0.3 damping - * - Entry: slides in from right (translationX 20dp→0) with fade - * - * Reference: ChatActivityEnterView.SlideTextView (lines 13083-13357) + * Port from VoiceRecordingPanel.updateCancelTranslation(): + * - don't move until |dx| > 8dp + * - then translationX = -(abs(dx) - 8) * 0.5 + * - fade out while dragging left + * - idle arrow jiggle only when close to rest */ @Composable private fun SlideToCancel( slideDx: Float, - cancelThresholdPx: Float, isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - // slideProgress: 1.0 = at rest, decreases as finger drags left toward cancel - val slideProgress = 1f - ((-slideDx) / cancelThresholdPx).coerceIn(0f, 1f) - val density = LocalDensity.current + val dragPx = (-slideDx).coerceAtLeast(0f) + val dragTransformThresholdPx = with(density) { 8.dp.toPx() } + val effectiveDragPx = (dragPx - dragTransformThresholdPx).coerceAtLeast(0f) + val slideTranslationX = -effectiveDragPx * 0.5f + val fadeDistancePx = with(density) { 90.dp.toPx() } + val contentAlpha = (1f - (effectiveDragPx / fadeDistancePx)).coerceIn(0f, 1f) - // Pre-compute px values for use in LaunchedEffect val maxOffsetPx = with(density) { 6.dp.toPx() } - val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() } // 12dp/s + val speedPxPerMs = with(density) { (3f / 250f).dp.toPx() } - // Telegram: arrow oscillates ±6dp only when slideProgress > 0.8 + // Arrow oscillation: only when near the resting position. var xOffset by remember { mutableFloatStateOf(0f) } var moveForward by remember { mutableStateOf(true) } - LaunchedEffect(slideProgress > 0.8f) { - if (slideProgress <= 0.8f) { + LaunchedEffect(contentAlpha > 0.85f) { + if (contentAlpha <= 0.85f) { xOffset = 0f moveForward = true return@LaunchedEffect } var lastTime = System.nanoTime() while (true) { - delay(16) // ~60fps + delay(16) val now = System.nanoTime() val dtMs = (now - lastTime) / 1_000_000f lastTime = now - val step = speedPxPerMs * dtMs if (moveForward) { xOffset += step - if (xOffset > maxOffsetPx) { - xOffset = maxOffsetPx - moveForward = false - } + if (xOffset > maxOffsetPx) { xOffset = maxOffsetPx; moveForward = false } } else { xOffset -= step - if (xOffset < -maxOffsetPx) { - xOffset = -maxOffsetPx - moveForward = true - } + if (xOffset < -maxOffsetPx) { xOffset = -maxOffsetPx; moveForward = true } } } } - // Colors — Telegram: key_chat_recordTime (gray), key_glass_defaultIcon (arrow) - val textColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) - val arrowColor = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF8E8E93) - - // Entry animation: slide in from right - var entered by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - entered = true - } - val entryTranslation by animateFloatAsState( - targetValue = if (entered) 0f else with(density) { 20.dp.toPx() }, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), - label = "slide_cancel_entry" - ) - val entryAlpha by animateFloatAsState( - targetValue = if (entered) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "slide_cancel_entry_alpha" - ) + val textColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F) + val arrowColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F) + Box( + modifier = modifier.clipToBounds() + ) { Row( - modifier = modifier + modifier = Modifier + .align(Alignment.Center) .graphicsLayer { - // Telegram: text follows finger × damping + entry slide + pulse offset - translationX = slideDx * 0.3f + entryTranslation + - xOffset * slideProgress - alpha = slideProgress * entryAlpha + translationX = slideTranslationX + xOffset * contentAlpha + alpha = contentAlpha }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - // Chevron arrow — Canvas-drawn, NOT text character - // Telegram: 4dp × 5dp chevron, stroke 1.6dp, round caps + // Telegram: arrow path 4×5dp, stroke 1.6dp, round caps+joins, total 10dp offset to text Canvas( - modifier = Modifier - .size(width = 10.dp, height = 14.dp) - .graphicsLayer { - translationX = xOffset * slideProgress - } + modifier = Modifier.size(width = 4.dp, height = 10.dp) ) { val midY = size.height / 2f - val arrowW = 4.dp.toPx() + val arrowW = size.width val arrowH = 5.dp.toPx() val strokeW = 1.6f.dp.toPx() - val startX = (size.width - arrowW) / 2f drawLine( color = arrowColor, - start = Offset(startX + arrowW, midY - arrowH), - end = Offset(startX, midY), + start = Offset(arrowW, midY - arrowH), + end = Offset(0f, midY), strokeWidth = strokeW, cap = androidx.compose.ui.graphics.StrokeCap.Round ) drawLine( color = arrowColor, - start = Offset(startX, midY), - end = Offset(startX + arrowW, midY + arrowH), + start = Offset(0f, midY), + end = Offset(arrowW, midY + arrowH), strokeWidth = strokeW, cap = androidx.compose.ui.graphics.StrokeCap.Round ) } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(6.dp)) - // "Slide to cancel" text — Telegram: 15sp, normal weight Text( text = "Slide to cancel", color = textColor, - fontSize = 15.sp, + fontSize = 14.sp, fontWeight = FontWeight.Normal, maxLines = 1 ) } + } } @Composable @@ -1045,13 +1156,24 @@ fun MessageInputBar( var voiceOutputFile by remember { mutableStateOf(null) } var isVoiceRecording by remember { mutableStateOf(false) } var isVoiceRecordTransitioning by remember { mutableStateOf(false) } + var isVoiceCancelAnimating by remember { mutableStateOf(false) } + var keepMicGestureCapture by remember { mutableStateOf(false) } var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) } var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) } var pressStartX by remember { mutableFloatStateOf(0f) } var pressStartY by remember { mutableFloatStateOf(0f) } + var rawSlideDx by remember { mutableFloatStateOf(0f) } + var rawSlideDy by remember { mutableFloatStateOf(0f) } var slideDx by remember { mutableFloatStateOf(0f) } var slideDy by remember { mutableFloatStateOf(0f) } var lockProgress by remember { mutableFloatStateOf(0f) } + var dragVelocityX by remember { mutableFloatStateOf(0f) } + var dragVelocityY by remember { mutableFloatStateOf(0f) } + var lastDragDx by remember { mutableFloatStateOf(0f) } + var lastDragDy by remember { mutableFloatStateOf(0f) } + var lastDragEventTimeMs by remember { mutableLongStateOf(0L) } + var didCancelHaptic by remember { mutableStateOf(false) } + var didLockHaptic by remember { mutableStateOf(false) } var pendingLongPressJob by remember { mutableStateOf(null) } var pendingRecordAfterPermission by remember { mutableStateOf(false) } var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) } @@ -1065,22 +1187,8 @@ fun MessageInputBar( var normalInputRowY by remember { mutableFloatStateOf(0f) } var recordingInputRowHeightPx by remember { mutableIntStateOf(0) } var recordingInputRowY by remember { mutableFloatStateOf(0f) } - fun inputJumpLog(msg: String) { - try { - val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()) - .format(java.util.Date()) - val dir = java.io.File(context.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val line = "$ts [InputJump] $msg\n" - // Write newest records to TOP so they are immediately visible in Crash Details preview. - fun writeNewestFirst(file: java.io.File, maxChars: Int = 220_000) { - val existing = if (file.exists()) runCatching { file.readText() }.getOrDefault("") else "" - file.writeText(line + existing.take(maxChars)) - } - writeNewestFirst(java.io.File(dir, "rosettadev1.txt")) - writeNewestFirst(java.io.File(dir, "rosettadev1_input.txt")) - } catch (_: Exception) {} + if (!INPUT_JUMP_LOG_ENABLED) return } fun inputHeightsSnapshot(): String { @@ -1091,15 +1199,29 @@ fun MessageInputBar( } fun setRecordUiState(newState: RecordUiState, reason: String) { - if (recordUiState == newState) return + // Temporary rollout: lock/pause flow disabled, keep a single recording state. + val normalizedState = when (newState) { + RecordUiState.LOCKED, RecordUiState.PAUSED -> RecordUiState.RECORDING + else -> newState + } + if (recordUiState == normalizedState) return val oldState = recordUiState - recordUiState = newState - inputJumpLog("recordState $oldState -> $newState reason=$reason mode=$recordMode") + recordUiState = normalizedState + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordState $oldState -> $normalizedState reason=$reason mode=$recordMode") } fun resetGestureState() { + rawSlideDx = 0f + rawSlideDy = 0f slideDx = 0f slideDy = 0f + dragVelocityX = 0f + dragVelocityY = 0f + lastDragDx = 0f + lastDragDy = 0f + lastDragEventTimeMs = 0L + didCancelHaptic = false + didLockHaptic = false pressStartX = 0f pressStartY = 0f lockProgress = 0f @@ -1109,19 +1231,25 @@ fun MessageInputBar( fun toggleRecordModeByTap() { recordMode = if (recordMode == RecordMode.VOICE) RecordMode.VIDEO else RecordMode.VOICE - inputJumpLog("recordMode toggled -> $recordMode (short tap)") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("recordMode toggled -> $recordMode (short tap)") } val shouldPinBottomForInput = isKeyboardVisible || coordinator.isEmojiBoxVisible || - isVoiceRecordTransitioning || - recordUiState == RecordUiState.PAUSED + isVoiceRecordTransitioning val shouldAddNavBarPadding = hasNativeNavigationBar && !shouldPinBottomForInput - fun stopVoiceRecording(send: Boolean) { + fun stopVoiceRecording( + send: Boolean, + preserveCancelAnimation: Boolean = false + ) { isVoiceRecordTransitioning = false - inputJumpLog( + if (!preserveCancelAnimation) { + isVoiceCancelAnimating = false + } + keepMicGestureCapture = false + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "stopVoiceRecording begin send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx" @@ -1154,31 +1282,46 @@ fun MessageInputBar( scope.launch(kotlinx.coroutines.Dispatchers.IO) { var recordedOk = false if (recorder != null) { - recordedOk = runCatching { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: calling recorder.stop() send=$send") + val stopResult = runCatching { recorder.stop() true - }.getOrDefault(false) + } + recordedOk = stopResult.getOrDefault(false) + if (stopResult.isFailure) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder.stop() FAILED: ${stopResult.exceptionOrNull()?.message}") + } + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder.stop() ok=$recordedOk, calling reset+release") runCatching { recorder.reset() } runCatching { recorder.release() } + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: recorder released") } if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) { + val fileSize = outputFile.length() + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: reading file ${outputFile.name} size=${fileSize}bytes duration=${durationSnapshot}s") val voiceHex = runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: hex length=${voiceHex.length} sending=${voiceHex.isNotBlank()}") if (voiceHex.isNotBlank()) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording Main: calling onSendVoiceMessage duration=$durationSnapshot waves=${wavesSnapshot.size}") onSendVoiceMessage( voiceHex, durationSnapshot, compressVoiceWaves(wavesSnapshot, 35) ) + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording Main: onSendVoiceMessage done") } } + } else if (send) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: NOT sending — recordedOk=$recordedOk file=${outputFile?.name} exists=${outputFile?.exists()} size=${outputFile?.length()}") } runCatching { outputFile?.delete() } + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("stopVoiceRecording IO: cleanup done") } - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " + "normalH=$normalInputRowHeightPx recH=$recordingInputRowHeightPx" @@ -1187,7 +1330,7 @@ fun MessageInputBar( fun startVoiceRecording() { if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" ) @@ -1210,10 +1353,13 @@ fun MessageInputBar( recorder.setAudioSamplingRate(48_000) recorder.setOutputFile(output.absolutePath) recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: calling prepare() file=${output.name}") recorder.prepare() + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: calling start()") recorder.start() + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording: recorder started OK") recorder.setOnErrorListener { _, what, extra -> - inputJumpLog("MediaRecorder error what=$what extra=$extra") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("MediaRecorder error what=$what extra=$extra") stopVoiceRecording(send = false) } @@ -1230,7 +1376,7 @@ fun MessageInputBar( if (showEmojiPicker || coordinator.isEmojiBoxVisible) { onToggleEmojiPicker(false) } - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording armed mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + "pinBottom=$shouldPinBottomForInput " + @@ -1248,14 +1394,14 @@ fun MessageInputBar( if (recordUiState == RecordUiState.PRESSING || recordUiState == RecordUiState.IDLE) { setRecordUiState(RecordUiState.RECORDING, "voice-recorder-started") } - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording ui-enter mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} transitioning=$isVoiceRecordTransitioning " + "panelH=$inputPanelHeightPx recH=$recordingInputRowHeightPx" ) } catch (e: Exception) { isVoiceRecordTransitioning = false - inputJumpLog("startVoiceRecording launch failed: ${e.message}") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("startVoiceRecording launch failed: ${e.message}") } } } catch (_: Exception) { @@ -1269,24 +1415,46 @@ fun MessageInputBar( } } + fun cancelVoiceRecordingWithAnimation(origin: String) { + if (isVoiceCancelAnimating) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "cancelVoiceRecordingWithAnimation already animating origin=$origin " + + "voice=$isVoiceRecording recorder=${voiceRecorder != null}" + ) + if (isVoiceRecording || voiceRecorder != null) { + stopVoiceRecording(send = false) + } + return + } + if (!isVoiceRecording && voiceRecorder == null) { + setRecordUiState(RecordUiState.IDLE, "cancel-no-recorder origin=$origin") + return + } + keepMicGestureCapture = false + isVoiceCancelAnimating = true + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancelVoiceRecordingWithAnimation start origin=$origin") + // Stop recorder immediately (off-main) to avoid stuck recording state / ANR on cancel. + stopVoiceRecording(send = false, preserveCancelAnimation = true) + } + fun pauseVoiceRecording() { val recorder = voiceRecorder ?: return if (!isVoiceRecording || isVoicePaused) return - inputJumpLog("pauseVoiceRecording mode=$recordMode state=$recordUiState") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("pauseVoiceRecording mode=$recordMode state=$recordUiState") try { recorder.pause() isVoicePaused = true voicePausedElapsedMs = voiceElapsedMs setRecordUiState(RecordUiState.PAUSED, "pause-pressed") } catch (e: Exception) { - inputJumpLog("pauseVoiceRecording failed: ${e.message}") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("pauseVoiceRecording failed: ${e.message}") } } fun resumeVoiceRecording() { val recorder = voiceRecorder ?: return if (!isVoiceRecording || !isVoicePaused) return - inputJumpLog("resumeVoiceRecording mode=$recordMode state=$recordUiState") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("resumeVoiceRecording mode=$recordMode state=$recordUiState") try { recorder.resume() voiceRecordStartedAtMs = System.currentTimeMillis() - voicePausedElapsedMs @@ -1294,32 +1462,34 @@ fun MessageInputBar( voicePausedElapsedMs = 0L setRecordUiState(RecordUiState.LOCKED, "resume-pressed") } catch (e: Exception) { - inputJumpLog("resumeVoiceRecording failed: ${e.message}") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("resumeVoiceRecording failed: ${e.message}") } } - LaunchedEffect(Unit) { - snapshotFlow { - val kb = coordinator.keyboardHeight.value.toInt() - val em = coordinator.emojiHeight.value.toInt() - val panelY = (inputPanelY * 10f).toInt() / 10f - val normalY = (normalInputRowY * 10f).toInt() / 10f - val recY = (recordingInputRowY * 10f).toInt() / 10f - val pinBottom = - isKeyboardVisible || - coordinator.isEmojiBoxVisible || - isVoiceRecordTransitioning || - recordUiState == RecordUiState.PAUSED - val navPad = hasNativeNavigationBar && !pinBottom - "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " + - "voice=$isVoiceRecording kbVis=$isKeyboardVisible kbDp=$kb emojiBox=${coordinator.isEmojiBoxVisible} " + - "emojiVisible=$showEmojiPicker emojiDp=$em suppress=$suppressKeyboard " + - "voiceTransitioning=$isVoiceRecordTransitioning " + - "pinBottom=$pinBottom navPad=$navPad " + - "panelH=$inputPanelHeightPx panelY=$panelY normalH=$normalInputRowHeightPx " + - "normalY=$normalY recH=$recordingInputRowHeightPx recY=$recY" - }.distinctUntilChanged().collect { stateLine -> - inputJumpLog(stateLine) + if (INPUT_JUMP_LOG_ENABLED) { + LaunchedEffect(Unit) { + snapshotFlow { + val kb = coordinator.keyboardHeight.value.toInt() + val em = coordinator.emojiHeight.value.toInt() + val panelY = (inputPanelY * 10f).toInt() / 10f + val normalY = (normalInputRowY * 10f).toInt() / 10f + val recY = (recordingInputRowY * 10f).toInt() / 10f + val pinBottom = + isKeyboardVisible || + coordinator.isEmojiBoxVisible || + isVoiceRecordTransitioning || + recordUiState == RecordUiState.PAUSED + val navPad = hasNativeNavigationBar && !pinBottom + "mode=$recordMode state=$recordUiState slideDx=${slideDx.toInt()} slideDy=${slideDy.toInt()} " + + "voice=$isVoiceRecording kbVis=$isKeyboardVisible kbDp=$kb emojiBox=${coordinator.isEmojiBoxVisible} " + + "emojiVisible=$showEmojiPicker emojiDp=$em suppress=$suppressKeyboard " + + "voiceTransitioning=$isVoiceRecordTransitioning " + + "pinBottom=$pinBottom navPad=$navPad " + + "panelH=$inputPanelHeightPx panelY=$panelY normalH=$normalInputRowHeightPx " + + "normalY=$normalY recH=$recordingInputRowHeightPx recY=$recY" + }.distinctUntilChanged().collect { stateLine -> + inputJumpLog(stateLine) + } } } @@ -1345,7 +1515,7 @@ fun MessageInputBar( } fun requestVoiceRecordingFromHold(): Boolean { - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "requestVoiceRecordingFromHold mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) @@ -1364,15 +1534,17 @@ fun MessageInputBar( } } - val holdToRecordDelayMs = 260L - val cancelDragThresholdPx = with(density) { 140.dp.toPx() } - val lockDragThresholdPx = with(density) { 70.dp.toPx() } + // iOS parity (RecordingMicButton.swift / VoiceRecordingParityMath.swift): + // hold=0.19s, cancel=-150, cancel-on-release=-100, velocityGate=-400. + val holdToRecordDelayMs = 190L + val preHoldCancelDistancePx = with(density) { 10.dp.toPx() } + val cancelDragThresholdPx = with(density) { 150.dp.toPx() } + val releaseCancelThresholdPx = with(density) { 100.dp.toPx() } + val velocityGatePxPerSec = -400f + val dragSmoothingPrev = 0.7f + val dragSmoothingNew = 0.3f var showLockTooltip by remember { mutableStateOf(false) } - val lockHintShownCount = remember { - context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) - .getInt(LOCK_HINT_PREF_KEY, 0) - } fun tryStartRecordingForCurrentMode(): Boolean { return if (recordMode == RecordMode.VOICE) { @@ -1410,26 +1582,46 @@ fun MessageInputBar( } } - LaunchedEffect(recordUiState) { - if (recordUiState == RecordUiState.RECORDING && lockHintShownCount < LOCK_HINT_MAX_SHOWS) { - delay(200) - if (recordUiState == RecordUiState.RECORDING) { - showLockTooltip = true - context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) - .edit() - .putInt(LOCK_HINT_PREF_KEY, lockHintShownCount + 1) - .apply() - delay(3000) - showLockTooltip = false - } + LaunchedEffect(recordUiState, lockProgress) { + showLockTooltip = false + } + + // Deterministic cancel commit: after animation, always finalize recording stop. + LaunchedEffect(isVoiceCancelAnimating) { + if (!isVoiceCancelAnimating) return@LaunchedEffect + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "cancel animation commit scheduled voice=$isVoiceRecording recorder=${voiceRecorder != null}" + ) + delay(220) + if (!isVoiceCancelAnimating) return@LaunchedEffect + if (isVoiceRecording || voiceRecorder != null) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancel animation commit -> stopVoiceRecording(send=false)") + stopVoiceRecording(send = false) } else { - showLockTooltip = false + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancel animation commit -> no recorder, reset UI") + isVoiceCancelAnimating = false + keepMicGestureCapture = false + setRecordUiState(RecordUiState.IDLE, "cancel-commit-no-recorder") + resetGestureState() } } - LaunchedEffect(lockProgress) { - if (lockProgress > 0.2f) { - showLockTooltip = false + // Safety guard: never allow cancel animation flag to stick and block UI. + LaunchedEffect(isVoiceCancelAnimating) { + if (!isVoiceCancelAnimating) return@LaunchedEffect + delay(1300) + if (isVoiceCancelAnimating) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "cancel animation watchdog: force-finish voice=$isVoiceRecording recorder=${voiceRecorder != null}" + ) + if (isVoiceRecording || voiceRecorder != null) { + stopVoiceRecording(send = false) + } else { + isVoiceCancelAnimating = false + keepMicGestureCapture = false + setRecordUiState(RecordUiState.IDLE, "cancel-watchdog-reset") + resetGestureState() + } } } @@ -1437,6 +1629,8 @@ fun MessageInputBar( onDispose { pendingRecordAfterPermission = false isVoiceRecordTransitioning = false + isVoiceCancelAnimating = false + keepMicGestureCapture = false resetGestureState() if (isVoiceRecording || voiceRecorder != null) { stopVoiceRecording(send = false) @@ -1896,9 +2090,19 @@ fun MessageInputBar( val hasCallAttachment = msg.attachments.any { it.type == AttachmentType.CALL } + val hasVoiceAttachment = msg.attachments.any { + it.type == AttachmentType.VOICE + } + val hasVideoCircleAttachment = msg.attachments.any { + it.type == AttachmentType.VIDEO_CIRCLE + } AppleEmojiText( text = if (panelReplyMessages.size == 1) { - if (msg.text.isEmpty() && hasCallAttachment) { + if (msg.text.isEmpty() && hasVoiceAttachment) { + "Voice Message" + } else if (msg.text.isEmpty() && hasVideoCircleAttachment) { + "Video Message" + } else if (msg.text.isEmpty() && hasCallAttachment) { "Call" } else if (msg.text.isEmpty() && hasImageAttachment) { "Photo" @@ -2081,19 +2285,41 @@ fun MessageInputBar( // Layer 1: panel bar (timer + center content) // Layer 2: mic/send circle OVERLAY at right edge (extends beyond panel) // Layer 3: lock icon ABOVE circle (extends above panel) + val isRecordingPanelVisible = isVoiceRecording || isVoiceCancelAnimating + val recordingPanelTransitionState = + remember { MutableTransitionState(false) }.apply { + targetState = isRecordingPanelVisible + } + // True while visible OR while enter/exit animation is still running. + val isRecordingPanelComposed = + recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState androidx.compose.animation.AnimatedVisibility( - visible = isVoiceRecording, - enter = fadeIn(tween(180)) + expandVertically(tween(180)), - exit = fadeOut(tween(300)) + shrinkVertically(tween(300)) + visibleState = recordingPanelTransitionState, + // Telegram-like smooth dissolve without any vertical resize. + enter = fadeIn(tween(durationMillis = 170, easing = LinearOutSlowInEasing)), + exit = fadeOut(tween(durationMillis = 210, easing = FastOutLinearInEasing)) ) { val recordingPanelColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) val recordingTextColor = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37) + // Keep layout height equal to normal input row. + // The button "grows" visually via scale, not via measured size. + val recordingActionButtonBaseSize = 40.dp + // Telegram-like proportions: large button that does not dominate the panel. + val recordingActionVisualScale = 1.42f // 40dp -> ~57dp visual size + val recordingActionInset = 34.dp + val recordingActionOverflowX = 8.dp + val recordingActionOverflowY = 10.dp val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f } + val cancelAnimProgress by animateFloatAsState( + targetValue = if (isVoiceCancelAnimating) 1f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "voice_cancel_anim" + ) var recordUiEntered by remember { mutableStateOf(false) } - LaunchedEffect(isVoiceRecording) { - if (isVoiceRecording) { + LaunchedEffect(isVoiceRecording, isVoiceCancelAnimating) { + if (isVoiceRecording || isVoiceCancelAnimating) { recordUiEntered = false delay(16) recordUiEntered = true @@ -2122,20 +2348,30 @@ fun MessageInputBar( .fillMaxWidth() .heightIn(min = 48.dp) .padding(horizontal = 8.dp, vertical = 8.dp) + .zIndex(2f) .onGloballyPositioned { coordinates -> recordingInputRowHeightPx = coordinates.size.height recordingInputRowY = coordinates.positionInWindow().y } ) { val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED + // iOS parity (VoiceRecordingOverlay.applyCurrentTransforms): + // valueX = abs(distanceX) / 300 + // innerScale = clamp(1 - valueX, 0.4..1) + // translatedX = distanceX * innerScale + val slideDistanceX = slideDx.coerceAtMost(0f) + val slideValueX = (kotlin.math.abs(slideDistanceX) / with(density) { 300.dp.toPx() }) + .coerceIn(0f, 1f) + val circleSlideCancelScale = (1f - slideValueX).coerceIn(0.4f, 1f) + val circleSlideDelta = slideDistanceX * circleSlideCancelScale // Crossfade between RECORDING panel and LOCKED panel AnimatedContent( targetState = isLockedOrPaused, modifier = Modifier .fillMaxWidth() - .height(44.dp) - .padding(end = 52.dp), // space for circle overlay + .height(48.dp) + .padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap) transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, @@ -2147,19 +2383,19 @@ fun MessageInputBar( Row( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(24.dp)) .background(recordingPanelColor), verticalAlignment = Alignment.CenterVertically ) { // Delete button — Telegram: 44×44dp, Lottie trash icon Box( modifier = Modifier - .size(44.dp) + .size(recordingActionButtonBaseSize) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { - inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState") + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("tap DELETE (locked/paused) mode=$recordMode state=$recordUiState") stopVoiceRecording(send = false) }, contentAlignment = Alignment.Center @@ -2187,7 +2423,7 @@ fun MessageInputBar( Row( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(22.dp)) + .clip(RoundedCornerShape(24.dp)) .background(recordingPanelColor) .padding(start = 13.dp), verticalAlignment = Alignment.CenterVertically @@ -2196,12 +2432,15 @@ fun MessageInputBar( Row( modifier = Modifier .graphicsLayer { - alpha = recordUiAlpha + alpha = recordUiAlpha * (1f - cancelAnimProgress) translationX = with(density) { recordUiShift.toPx() } }, verticalAlignment = Alignment.CenterVertically ) { - RecordBlinkDot(isDarkTheme = isDarkTheme) + TelegramVoiceDeleteIndicator( + cancelProgress = cancelAnimProgress, + isDarkTheme = isDarkTheme + ) Spacer(modifier = Modifier.width(6.dp)) Text( text = formatVoiceRecordTimer(voiceElapsedMs), @@ -2216,12 +2455,11 @@ fun MessageInputBar( // Slide to cancel SlideToCancel( slideDx = slideDx, - cancelThresholdPx = cancelDragThresholdPx, isDarkTheme = isDarkTheme, modifier = Modifier .weight(1f) .graphicsLayer { - alpha = recordUiAlpha + alpha = recordUiAlpha * (1f - cancelAnimProgress) translationX = with(density) { recordUiShift.toPx() } } ) @@ -2230,23 +2468,26 @@ fun MessageInputBar( } // ── Layer 2: Circle + Lock overlay ── - // 48dp layout box at right edge; visuals overflow via graphicsLayer - // Telegram: circle center at 26dp from right, radius 41dp = 82dp visual Box( modifier = Modifier - .size(48.dp) - .align(Alignment.CenterEnd) - .offset(x = 4.dp) // slight overlap into right padding + .size(recordingActionButtonBaseSize) + .align(Alignment.BottomEnd) + .offset(x = recordingActionOverflowX, y = recordingActionOverflowY) + .graphicsLayer { + translationX = circleSlideDelta + scaleX = recordingActionVisualScale * circleSlideCancelScale + scaleY = recordingActionVisualScale * circleSlideCancelScale + transformOrigin = TransformOrigin(0.5f, 0.5f) + } .zIndex(5f), contentAlignment = Alignment.Center ) { - // Lock icon: floats ~70dp above circle center (Telegram: ~92dp) - if (recordUiState == RecordUiState.RECORDING || - recordUiState == RecordUiState.LOCKED || + // Lock icon above circle + if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED ) { - val lockSizeDp = 50.dp - 14.dp * lockProgress - val lockYDp = -70.dp + 14.dp * lockProgress + val lockSizeDp = 36.dp + 10.dp * (1f - lockProgress) + val lockYDp = -80.dp + 14.dp * lockProgress LockIcon( lockProgress = lockProgress, isLocked = recordUiState == RecordUiState.LOCKED, @@ -2268,7 +2509,7 @@ fun MessageInputBar( modifier = Modifier .graphicsLayer { translationX = with(density) { (-90).dp.toPx() } - translationY = with(density) { (-70).dp.toPx() } + translationY = with(density) { (-80).dp.toPx() } clip = false } .zIndex(11f) @@ -2276,36 +2517,34 @@ fun MessageInputBar( } } - // Blob: only during RECORDING (Telegram hides waves when locked) - if (recordUiState == RecordUiState.RECORDING) { + // Blob: only during RECORDING + if (recordUiState == RecordUiState.RECORDING && !isVoiceCancelAnimating) { VoiceButtonBlob( voiceLevel = voiceLevel, isDarkTheme = isDarkTheme, modifier = Modifier - .size(48.dp) + .size(recordingActionButtonBaseSize) .graphicsLayer { - scaleX = 1.7f - scaleY = 1.7f + scaleX = recordingActionVisualScale * 1.1f + scaleY = recordingActionVisualScale * 1.1f clip = false } ) } - // Solid circle: 48dp layout, scaled to 82dp visual + // Mic/Send circle — same size as panel height val sendScale by animateFloatAsState( targetValue = if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) 1f else 0f, animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), label = "send_btn_scale" ) - val circleScale = 1.71f // 48dp * 1.71 ≈ 82dp (Telegram) if (recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED) { Box( modifier = Modifier - .size(48.dp) + .size(recordingActionButtonBaseSize) .graphicsLayer { - scaleX = circleScale * sendScale - scaleY = circleScale * sendScale - clip = false + scaleX = sendScale * recordingActionVisualScale + scaleY = sendScale * recordingActionVisualScale shadowElevation = 8f shape = CircleShape } @@ -2315,7 +2554,7 @@ fun MessageInputBar( interactionSource = remember { MutableInteractionSource() }, indication = null ) { - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "tap SEND (locked/paused) mode=$recordMode state=$recordUiState voice=$isVoiceRecording " + "kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) @@ -2327,17 +2566,18 @@ fun MessageInputBar( imageVector = TelegramSendIcon, contentDescription = "Send voice message", tint = Color.White, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(20.dp) ) } } else { Box( modifier = Modifier - .size(48.dp) + .size(recordingActionButtonBaseSize) .graphicsLayer { - scaleX = circleScale - scaleY = circleScale - clip = false + scaleX = recordingActionVisualScale + scaleY = recordingActionVisualScale + shadowElevation = 8f + shape = CircleShape } .clip(CircleShape) .background(PrimaryBlue), @@ -2347,23 +2587,28 @@ fun MessageInputBar( imageVector = if (recordMode == RecordMode.VOICE) Icons.Default.Mic else Icons.Default.Videocam, contentDescription = null, tint = Color.White, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(19.dp) ) } } } } } - if (!isVoiceRecording) { + if (!isRecordingPanelComposed || keepMicGestureCapture) { Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp) + .zIndex(1f) + .graphicsLayer { + // Keep gesture layer alive during hold, but never show base input under recording panel. + alpha = if (isRecordingPanelComposed) 0f else 1f + } .onGloballyPositioned { coordinates -> - normalInputRowHeightPx = coordinates.size.height - normalInputRowY = coordinates.positionInWindow().y - }, + normalInputRowHeightPx = coordinates.size.height + normalInputRowY = coordinates.positionInWindow().y + }, verticalAlignment = Alignment.Bottom ) { IconButton( @@ -2401,7 +2646,7 @@ fun MessageInputBar( onViewCreated = { view -> editTextView = view }, onFocusChanged = { hasFocus -> if (hasFocus) { - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "tap INPUT focus=true voice=$isVoiceRecording kb=$isKeyboardVisible " + "emojiBox=${coordinator.isEmojiBoxVisible} ${inputHeightsSnapshot()}" ) @@ -2471,15 +2716,26 @@ fun MessageInputBar( val down = awaitFirstDown(requireUnconsumed = false) val tapSlopPx = viewConfiguration.touchSlop var pointerIsDown = true + var armingCancelledByMove = false var maxAbsDx = 0f var maxAbsDy = 0f pressStartX = down.position.x pressStartY = down.position.y + keepMicGestureCapture = true + rawSlideDx = 0f + rawSlideDy = 0f slideDx = 0f slideDy = 0f + dragVelocityX = 0f + dragVelocityY = 0f + lastDragDx = 0f + lastDragDy = 0f + lastDragEventTimeMs = System.currentTimeMillis() + didCancelHaptic = false + didLockHaptic = false pendingRecordAfterPermission = false setRecordUiState(RecordUiState.PRESSING, "mic-down") - inputJumpLog( + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "mic DOWN mode=$recordMode state=$recordUiState " + "voice=$isVoiceRecording kb=$isKeyboardVisible ${inputHeightsSnapshot()}" ) @@ -2497,52 +2753,97 @@ fun MessageInputBar( } } - var finished = false - while (!finished) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } - ?: event.changes.firstOrNull() - ?: continue - - if (change.changedToUpIgnoreConsumed()) { - pointerIsDown = false - pendingLongPressJob?.cancel() - pendingLongPressJob = null - pendingRecordAfterPermission = false - when (recordUiState) { - RecordUiState.PRESSING -> { - val movedBeyondTap = - maxAbsDx > tapSlopPx || maxAbsDy > tapSlopPx - if (!movedBeyondTap) { - toggleRecordModeByTap() - setRecordUiState(RecordUiState.IDLE, "short-tap-toggle") - } else { - setRecordUiState(RecordUiState.IDLE, "press-release-after-move") - } + fun finalizePointerRelease( + rawReleaseDx: Float, + rawReleaseDy: Float, + source: String + ) { + keepMicGestureCapture = false + pointerIsDown = false + pendingLongPressJob?.cancel() + pendingLongPressJob = null + pendingRecordAfterPermission = false + when (recordUiState) { + RecordUiState.PRESSING -> { + val movedBeyondTap = + maxAbsDx > tapSlopPx || maxAbsDy > tapSlopPx + if (!movedBeyondTap) { + toggleRecordModeByTap() + setRecordUiState(RecordUiState.IDLE, "$source-short-tap-toggle") + } else { + setRecordUiState(RecordUiState.IDLE, "$source-press-release-after-move") } - RecordUiState.RECORDING -> { - inputJumpLog( - "mic UP -> send (unlocked) mode=$recordMode state=$recordUiState" - ) + } + RecordUiState.RECORDING -> { + // iOS parity: + // - dominant-axis release evaluation + // - velocity gate (-400 px/s) + // - fallback to distance thresholds. + var releaseDx = rawReleaseDx.coerceAtMost(0f) + var releaseDy = rawReleaseDy.coerceAtMost(0f) + if (kotlin.math.abs(releaseDx) > kotlin.math.abs(releaseDy)) { + releaseDy = 0f + } else { + releaseDx = 0f + } + val cancelOnRelease = + dragVelocityX <= velocityGatePxPerSec || + releaseDx <= -releaseCancelThresholdPx + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source mode=$recordMode state=$recordUiState releaseDx=${releaseDx.toInt()} " + + "releaseDy=${releaseDy.toInt()} vX=${dragVelocityX.toInt()} vY=${dragVelocityY.toInt()} " + + "cancel=$cancelOnRelease" + ) + if (cancelOnRelease) { + if (isVoiceRecording || voiceRecorder != null) { + cancelVoiceRecordingWithAnimation("$source-cancel") + } else { + setRecordUiState(RecordUiState.IDLE, "$source-cancel-without-recorder") + } + } else { if (isVoiceRecording || voiceRecorder != null) { stopVoiceRecording(send = true) } else { - setRecordUiState(RecordUiState.IDLE, "release-without-recorder") + setRecordUiState(RecordUiState.IDLE, "$source-without-recorder") } } - RecordUiState.LOCKED -> { - inputJumpLog( - "mic UP while LOCKED -> keep recording mode=$recordMode state=$recordUiState" - ) - } - RecordUiState.PAUSED -> { - inputJumpLog( - "mic UP while PAUSED -> stay paused mode=$recordMode state=$recordUiState" - ) - } - RecordUiState.IDLE -> Unit } - resetGestureState() + RecordUiState.LOCKED -> { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source while LOCKED -> keep recording mode=$recordMode state=$recordUiState" + ) + } + RecordUiState.PAUSED -> { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "$source while PAUSED -> stay paused mode=$recordMode state=$recordUiState" + ) + } + RecordUiState.IDLE -> Unit + } + resetGestureState() + } + + var finished = false + while (!finished) { + val event = awaitPointerEvent() + val trackedChange = event.changes.firstOrNull { it.id == down.id } + val change = trackedChange + ?: event.changes.firstOrNull() + ?: continue + val allPointersReleased = event.changes.none { it.pressed } + val releaseDetected = + change.changedToUpIgnoreConsumed() || !change.pressed || + (trackedChange == null && allPointersReleased) + + if (releaseDetected) { + val releaseDx = + if (trackedChange != null) change.position.x - pressStartX else rawSlideDx + val releaseDy = + if (trackedChange != null) change.position.y - pressStartY else rawSlideDy + val source = + if (trackedChange == null) "mic UP fallback-lost-pointer" + else "mic UP" + finalizePointerRelease(releaseDx, releaseDy, source) finished = true } else if (recordUiState == RecordUiState.PRESSING) { val dx = change.position.x - pressStartX @@ -2551,33 +2852,49 @@ fun MessageInputBar( val absDy = kotlin.math.abs(dy) if (absDx > maxAbsDx) maxAbsDx = absDx if (absDy > maxAbsDy) maxAbsDy = absDy - } else if (recordUiState == RecordUiState.RECORDING) { - // Only RECORDING processes slide gestures - // LOCKED/PAUSED: no gesture processing (Telegram: return false) - val dx = change.position.x - pressStartX - val dy = change.position.y - pressStartY - slideDx = dx - slideDy = dy - lockProgress = ((-dy) / lockDragThresholdPx).coerceIn(0f, 1f) - - if (dx <= -cancelDragThresholdPx) { - inputJumpLog( - "gesture CANCEL dx=${dx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" - ) - stopVoiceRecording(send = false) - setRecordUiState(RecordUiState.IDLE, "slide-cancel") - resetGestureState() - finished = true - } else if (dy <= -lockDragThresholdPx) { - view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) - lockProgress = 1f - slideDx = 0f // reset horizontal slide on lock - slideDy = 0f + val totalDistance = kotlin.math.sqrt(dx * dx + dy * dy) + if (!armingCancelledByMove && totalDistance > preHoldCancelDistancePx) { + armingCancelledByMove = true + pendingLongPressJob?.cancel() + pendingLongPressJob = null + pendingRecordAfterPermission = false + keepMicGestureCapture = false setRecordUiState( - RecordUiState.LOCKED, - "slide-lock dy=${dy.toInt()}" + RecordUiState.IDLE, + "pre-hold-move-cancel dist=${totalDistance.toInt()}" ) } + } else if (recordUiState == RecordUiState.RECORDING) { + // iOS parity: + // raw drag from touch + smoothed drag for UI (0.7 / 0.3). + val rawDx = (change.position.x - pressStartX).coerceAtMost(0f) + val rawDy = (change.position.y - pressStartY).coerceAtMost(0f) + rawSlideDx = rawDx + rawSlideDy = rawDy + + val nowMs = System.currentTimeMillis() + val dtMs = (nowMs - lastDragEventTimeMs).coerceAtLeast(1L).toFloat() + dragVelocityX = ((rawDx - lastDragDx) / dtMs) * 1000f + dragVelocityY = ((rawDy - lastDragDy) / dtMs) * 1000f + lastDragDx = rawDx + lastDragDy = rawDy + lastDragEventTimeMs = nowMs + + slideDx = (slideDx * dragSmoothingPrev) + (rawDx * dragSmoothingNew) + slideDy = (slideDy * dragSmoothingPrev) + (rawDy * dragSmoothingNew) + lockProgress = 0f + + if (!didCancelHaptic && rawDx <= -releaseCancelThresholdPx) { + didCancelHaptic = true + view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) + } + if (rawDx <= -cancelDragThresholdPx) { + if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( + "gesture CANCEL dx=${rawDx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" + ) + cancelVoiceRecordingWithAnimation("slide-cancel") + finished = true + } } change.consume() } @@ -2585,6 +2902,7 @@ fun MessageInputBar( pendingLongPressJob?.cancel() pendingLongPressJob = null if (recordUiState == RecordUiState.PRESSING) { + keepMicGestureCapture = false setRecordUiState(RecordUiState.IDLE, "gesture-end") resetGestureState() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index 23a13d7..15ef92d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.findViewTreeLifecycleOwner import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -160,7 +163,7 @@ fun SwipeBackContainer( // Alpha animation for fade-in entry val alphaAnimatable = remember { Animatable(0f) } - // Drag state - direct update without animation + // Drag state var dragOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } @@ -177,6 +180,7 @@ fun SwipeBackContainer( val context = LocalContext.current val view = LocalView.current val focusManager = LocalFocusManager.current + val lifecycleOwner = view.findViewTreeLifecycleOwner() val dismissKeyboard: () -> Unit = { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) @@ -187,21 +191,16 @@ fun SwipeBackContainer( focusManager.clearFocus(force = true) } - // Current offset: use drag offset during drag, animatable otherwise + optional enter slide - val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value - val enterOffset = - if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { - enterOffsetAnimatable.value - } else { - 0f - } - val currentOffset = baseOffset + enterOffset + fun computeCurrentOffset(): Float { + val base = if (isDragging) dragOffset else offsetAnimatable.value + val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { + enterOffsetAnimatable.value + } else 0f + return base + enter + } // Current alpha: use animatable during fade animations, otherwise 1 val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f - - // Scrim alpha based on swipe progress - val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f val sharedOwnerId = SwipeBackSharedProgress.ownerId val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer val sharedProgress = SwipeBackSharedProgress.progress @@ -239,6 +238,21 @@ fun SwipeBackContainer( } } + fun forceResetSwipeState() { + isDragging = false + dragOffset = 0f + clearSharedSwipeProgressIfOwner() + scope.launch { + offsetAnimatable.snapTo(0f) + if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) { + enterOffsetAnimatable.snapTo(0f) + } + if (shouldShow && !isAnimatingOut) { + alphaAnimatable.snapTo(1f) + } + } + } + // Handle visibility changes // 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if // LaunchedEffect is cancelled by rapid isVisible changes (fast swipes). @@ -292,10 +306,34 @@ fun SwipeBackContainer( } } - DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } } + DisposableEffect(lifecycleOwner) { + if (lifecycleOwner == null) { + onDispose { } + } else { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + forceResetSwipeState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + } + + DisposableEffect(Unit) { + onDispose { + forceResetSwipeState() + } + } if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return + val currentOffset = computeCurrentOffset() + val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f) + val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f + Box( modifier = Modifier.fillMaxSize().graphicsLayer { @@ -346,13 +384,15 @@ fun SwipeBackContainer( var totalDragY = 0f var passedSlop = false var keyboardHiddenForGesture = false + var resetOnFinally = true // deferToChildren=true: pre-slop uses Main pass so children // (e.g. LazyRow) process first — if they consume, we back off. // deferToChildren=false (default): always use Initial pass // to intercept before children (original behavior). // Post-claim: always Initial to block children. - while (true) { + try { + while (true) { val pass = if (startedSwipe || !deferToChildren) PointerEventPass.Initial @@ -365,6 +405,7 @@ fun SwipeBackContainer( ?: break if (change.changedToUpIgnoreConsumed()) { + resetOnFinally = false break } @@ -443,6 +484,13 @@ fun SwipeBackContainer( ) change.consume() } + } + } finally { + // Сбрасываем только при отмене/прерывании жеста. + // При обычном UP сброс делаем позже, чтобы не было рывка. + if (resetOnFinally && isDragging) { + forceResetSwipeState() + } } // Handle drag end diff --git a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt index 77ecebd..c6b468c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/qr/MyQrCodeScreen.kt @@ -92,16 +92,8 @@ fun MyQrCodeScreen( val scope = rememberCoroutineScope() var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) } - - // Auto-switch to matching theme group when app theme changes - LaunchedEffect(isDarkTheme) { - val currentTheme = qrThemes.getOrNull(selectedThemeIndex) - if (currentTheme != null && currentTheme.isDark != isDarkTheme) { - // Map to same position in the other group - val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3 - selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5) - } - } + // Local dark/light state — independent from the global app theme + var localIsDark by remember { mutableStateOf(isDarkTheme) } val theme = qrThemes[selectedThemeIndex] @@ -272,7 +264,7 @@ fun MyQrCodeScreen( Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, + color = if (localIsDark) Color(0xFF1C1C1E) else Color.White, shadowElevation = 16.dp ) { Column( @@ -299,16 +291,17 @@ fun MyQrCodeScreen( ) { IconButton(onClick = onBack) { Icon(TablerIcons.X, contentDescription = "Close", - tint = if (isDarkTheme) Color.White else Color.Black) + tint = if (localIsDark) Color.White else Color.Black) } Spacer(modifier = Modifier.weight(1f)) Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold, - color = if (isDarkTheme) Color.White else Color.Black) + color = if (localIsDark) Color.White else Color.Black) Spacer(modifier = Modifier.weight(1f)) var themeButtonPos by remember { mutableStateOf(Offset.Zero) } IconButton( onClick = { - // Snapshot → toggle theme → circular reveal + // Snapshot → toggle LOCAL theme → circular reveal + // Does NOT toggle the global app theme val now = System.currentTimeMillis() if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) { lastRevealTime = now @@ -319,11 +312,10 @@ fun MyQrCodeScreen( revealActive = true revealCenter = themeButtonPos revealSnapshot = snapshot.asImageBitmap() - // Switch to matching wallpaper in new theme - val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3 - val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2) + val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3 + val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2) selectedThemeIndex = newIndex - onToggleTheme() + localIsDark = !localIsDark scope.launch { try { revealRadius.snapTo(0f) @@ -337,11 +329,8 @@ fun MyQrCodeScreen( revealActive = false } } - } else { - // drawToBitmap failed — skip } } - // else: cooldown active — ignore tap }, modifier = Modifier.onGloballyPositioned { coords -> val pos = coords.positionInRoot() @@ -350,9 +339,9 @@ fun MyQrCodeScreen( } ) { Icon( - imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars, + imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars, contentDescription = "Toggle theme", - tint = if (isDarkTheme) Color.White else Color.Black + tint = if (localIsDark) Color.White else Color.Black ) } } @@ -360,7 +349,7 @@ fun MyQrCodeScreen( Spacer(modifier = Modifier.height(12.dp)) // Wallpaper selector — show current theme's wallpapers - val currentThemes = qrThemes.filter { it.isDark == isDarkTheme } + val currentThemes = qrThemes.filter { it.isDark == localIsDark } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), @@ -394,7 +383,7 @@ fun MyQrCodeScreen( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } Icon(TablerIcons.Scan, contentDescription = null, - tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f), + tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f), modifier = Modifier.size(22.dp)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt index 66402a9..0555844 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt @@ -43,10 +43,10 @@ data class AppIconOption( ) private val iconOptions = listOf( - AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.ic_launcher_foreground, Color(0xFF1B1B1B)), - AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color(0xFF795548)), - AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color(0xFF42A5F5)), - AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color(0xFFFFC107)) + AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)), + AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White), + AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White), + AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White) ) @Composable @@ -173,9 +173,8 @@ fun AppIconScreen( .background(option.previewBg), contentAlignment = Alignment.Center ) { - // Default icon has 15% inset built-in — show full size - val iconSize = if (option.id == "default") 52.dp else 36.dp - val scaleType = if (option.id == "default") + val imgSize = if (option.id == "default") 52.dp else 44.dp + val imgScale = if (option.id == "default") android.widget.ImageView.ScaleType.CENTER_CROP else android.widget.ImageView.ScaleType.FIT_CENTER @@ -183,10 +182,10 @@ fun AppIconScreen( factory = { ctx -> android.widget.ImageView(ctx).apply { setImageResource(option.iconRes) - this.scaleType = scaleType + scaleType = imgScale } }, - modifier = Modifier.size(iconSize) + modifier = Modifier.size(imgSize) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt index 347f189..fde1308 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/splash/SplashScreen.kt @@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash import androidx.compose.animation.core.* import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* @@ -64,7 +66,11 @@ fun SplashScreen( Box( modifier = Modifier .fillMaxSize() - .background(backgroundColor), + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { }, contentAlignment = Alignment.Center ) { // Glow effect behind logo diff --git a/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png new file mode 100644 index 0000000000000000000000000000000000000000..e6fcf21b0a6ef4771999f880460ce9a77e9c718b GIT binary patch literal 12147 zcmd^l3pA8#`}Z>ll^i-rghZ4gB$QJqjdRH%Vu~b*5jl++R69urw(}`Ag~Awe%4tR= z!bmxcoMse8Ou~%8jMM)fyY|}ex4v(${eQpp|JM6{@2tm~F>^oneLeSmoqoUTx@To! zylKPE4G;uvf}b)v13_HiBNxQa3*Kgfy4c`tz0aw0eh{>A3+E38rKazIAZZA0bo^{k z`sCvfADezmvQGKnUNw(6c;{32vE>5q)hitV=wa&7Eq32F1r$-{>JqDbV3U5c23)Co+P`S_pS#CoT}*JI~4xmfl;1N^xzS1XRkAht60l$}u{}=XiVf6wdF1?0L3r+a?v2{Z(UF)B92O*Gm#jd#+2Y zDm)tADl((BQu0mAa_*6rX_C_N%WwNgi#s9h+fh+bVPN(~%}2^_uP?caeKHbO(OdD5 zXaY-+g_~ydipf)ubI|_%`$J90jrX&^8b~zhy|3SUGg3<-nQFM z-G-F60s#oZCqBNHM|P(m`6Cw$2x`TI?CdX7j#KxcD(aTZg$8Bg)3aEGs1-iHIIv2qB3ck@=#%G*35| zr7drfKDA#_QBNV0p{M2Jic#4k^S)<(6kTtsTsM1>w!Bq(#~00&9EGR|RUgGczmJ6gN}2Kq(w#j%lC#C7+}woY4^8@obCt0 z_%_GN;m%E!LMeNqvelQ8g=DC`3Q?h(Pn^awLpR2VB_24}u`p8qfVD0v@@wz>6NRhA z*|F%(r(VuXt@e5$GJs8qKUAC&604VHjhT zj~X=;f`^zoH`NM}?+w4~V7nL^5mT2=LL;em&cUNoMyF>Hh02GG8U)zOZmo&Sg-qQ| zLw6LS1TPKAfRXt~$Ei+e0&Mo`Dn2TJF|4mrLP$eq+IQZtmM=)DED>_tIWNdViqZC)` z!nhA?uvh`dL{W&U#?B+d*IA!C#{#Olf+UG0lrGlFf ziymc1_+q-Wz5qM#A1;lDN#_NH5FB0c z*QH%_$?JmNu6W);Z-HJ*KN=2by`M8d%haG81{U9v;j$!HF9{};j$DQL7&h4vH3W=^ z(c*a4Hd-Jw=Qu@!ER0Uy=%BmwhWb>oB5p>qt}kYdlBS^aVvd|KtM#wxF04T$?hxhB z&k3Q`eZs%o^oQO(ccN|L7Gb#Z;@9g#`a6W7$YlBT5M;~`84=@npzu9BkPH>h1=Wji zK_U!U7&OEW3y%zrg&?0_ApHhyI0R`LK+-N5vJfP(8xmHr+zdgF{$EV<@qJwqAvJWE zIA)U&*4boV-n8#K0q=lG|6S_r#|gWnOc7TXjPqLvW4gBGg zcN5c)J0g>%(u9=cA;^F~d=mxThJK^pu%r;O0R{={L$Zj~TWfk(GU1u|`xrajngf%| z-Va&1HzPkQhBPC?E{lP&22do>aS?SiqhAq=T;YL!G2G5I)T-zV4M@C z2%GNeqbld##fi3QY&9a{?;_9Uskb2xQ!4o({@B8G;R<;{X>+xS0U3xm$>D2|%=_7p za*wepb&t*ryH3hV8S7pc7xY^0KJ3t<=dA>rOt_tTsFL{+G)x3x zbh9ky_=N?cyDlTMP@4uU1{rveIT^I3*+Uv$XO=e6~+TX*})U=OG&iLM6*?v~+_Jb3Rm+F-Ks)8BRvhs5E z$**4zAqoa-0`vMSk(QpGo^3sD6{>s1(jI(7Jsv5EZ8DQQM)OZ;&6R}*;+!9cdUof} z7MG~4_JsLN551cG{N^q-t)xJe#LXudk9j;n zqjfUI+TB%SGDg3Cy|T}~rOwvZFoEEu6J|H6lF(!;IqZw;Zq^{5TK!VqLqyVxTI?-r zXChd5byLo)iZE&12a8NyFFZ@BYh`8S1=hye+S*E^EJYVJ`)u~KNfpUtC2`4|7OZR( z{9v?1sg-G`@bnFi;2mm+cxDLR@ps!|fMvZifh3Lv5Yca(3hV0X$UV=itB=+NOs2BV zi#{2MX`QhMucZqysjFRS8pvFAb#-#j1hFN7sxkAnkKmib(w#Hnh_ewUEza!fif3J} zv3C>1>k)MPC*oP!<<5g_?Jd%)0nesliMH=k7tj`y_N99<%G?^$Z}F9w#Rs%6yXOxEK#we zfl105gRL2-fR5Yqg8bn{t2P8K%(2(lVO0ba>a!o%FfdY7{dc{4@Fe``il) z->8+kaKYuO+Kt8Jl{6SI2Tr6e(5JQ8^uK>^q3kI)rDWf3Zp}KH5#3%n8nSY3IiF%s z7c`TdG}e`6Q$R%a^+}#DkAfin-4L72CTiGujryUaJk@n|y>&IbFzG2V+z}*3N5Hjf z#8$tF!EK5}`^cU-N|g z@=)Q}sG$}XS7#B-=B+nd55TGZHZFYxSG^PG3Wp%8JJ5$D$Y<@6hM=>%p;FC04a5BWP z+{q%Rny#MbSZo{GxsOZ;qkscG#<8h4q`6~?wjk@-&7+S%WU{FFSYmUN!^7z4w084; zD)lncKQ^or<=pq^;ltoyi@F+MI-49FQvgE%V*JR=WZ$Dlg36u=mC>e>I;eP3SJyL~ zwU?LIQ7tVGT|IN<^Dl=+wfud$_cO#RD)?-%GHb~~L`1}mt5;jlx4X|!Uj$4#D0_By zcDjP`cOxQdb#M`P@3xKQc?1SlCiy!g$&QEM>Mn&l4W@yeSTO>rCkMH5Q^Ho zJ@_c!&@gmClCXYy`tzHzl9G@84x{7JL(i6B`ajI`oqI)akf=Vj*sK2t&eNYh0%rtY z)l^lD-G2WBk|w)4_x0;DmbSWf=+eucv93q?`LlJ}wd^ovcUew;%MIla8IBje0||$O zgtTe&y+zG_Jv~J`>*Vy_4Yi+<8^fczn{0#6O`2`^R1<4 zVwc)tZowrxU%h%&R#a5<&C`GBp+<+E&{wP99x(fnwHNWowpfnia)7jTzU|EnA>%Q} z;xshHIR*xxl`^#d-WW}I&ZkSc{~u)oY6}Hj_UY!?jfAl$%hX) z(Q+JnUaQyZ`Gez)Ul>MnE(2vA*1(B4uy=9M%3~1=ahm&@#(Eewjk2zBDh{|fZCiS> z*TBnAx)v88PbpLoD;wZH(t&>C|l|6a#28|7vB0u(~;B~NAtU4m8mlLX0PtyEj zn@l9tg1;5^xGD7%Ii-2`zK|O%aW$^?8?*Rhrr)>bKJ3GX4>1{<*JXBziNW*o@;aNM zrQA)#;vSecD!7)PSFHMVlb~Sk{P#gMaNz0b>1KED-Zj;zaVfb{VL`9_xzEH=kjj`w zyZQN*j{D^U&BH8CR(7hnzmF%;Cb|n!&NR-xKQgV*p*HEeH7>wSc z*Oj(vjU?Bm0CyIIP-F!)u=nEaYpSb>^Cj|}g!CLg)cY(iHpbGv zbLIqTECpg!8e)i&dqK0sL<4az`&4m@>*At@7k@ZQP#by@8RK~4jL3=caxM> zQ1STjV`q{H*{L`qEtl@EEtQFEonIN#3Kx0Fc>P(B31c6^2W5ZwC*1G3P{*JjvYBd9o6a{9Q69r9l_|D3gE?B9Y!aI zV~wLFb3oLapq+JeOaoXYwcWCFqo`6)utj-AMJlDZ9K&eSSPz8@bN!Z;wfk{usw(9| zd3;r&Qw^!rfjK*y490^WknIJZKVM|9JSJ#1UO*%dY$3AGq2)ZVW$*SiN@WmLULSJX~F$RF=1N9H(f`^yRd>fv`)0{S{t}YFl~B zjKi6E5qtLR@hGvM1>O%C{pe9@yR{)jqx_+`2K9>*l=3EGlepRZi=VEbq_VOH{luvd zJRB2m(cRrW&ZPCUja7eYU%^PMq)n2rj-B`Jt!J>58pSodgS6yT`iqK`7~%^qOxB|g zZ4Nu|VlzaK!V(A+0B4@5&iSr^qGFu)D3uU6MFtoNM2K;uLwiWpRBR!FfWOv5$uhi4 zM1I+5)979I2OuJ9f8E|OHlRuZXP1O|JTgtH@*BIwzAjyrl`x#JdTkULvDJ*8EGQ_L zDsj2L)ZG%rqPsD~s|)Ok&hLBJi=w2;agv>KC7$zKSm~Y6_?6bM ziuU$)Z_hBF8mMx`EBWoi;YZ5|wK_D0+UnT8Fh?>qvE&j{@fLkYOY|w0t}$I(PMeIP zVP}acYE+E{_h9zyc*hPkpD$rbYkLn8{n1&!|9ws{9x_Nj#zXRyBTUO*Kl7*^H&(C) zdCQ#Tg9}Gg;%*0#X!OFsR8S;{{r;(B&tR2b-t(Ve_jly{@5aUy^3_-S(>f9p6U*xA zbOtMtnSVSG)t5nS^8f@w-^Y&^@wPRAQx=s-N(RUfQgN-<+{{#uh&^E*ZFZH5PSFk^ z(NvQ>dy32}|Ib_VKS3(k0Q`Vop6ee^QFAw;`7t)~ zl+;evMK63ns|DCJqK4{WapvqL79&OAsk_$EqYfJqeQ^l`R-;JAc8X1~*=R}m%c|y@ z^FTvY2+cM7T|o+h`+H=TK;sx_uIjeGI)&%-V#>l016j9a<=DH%HqO zAkBbp$*hPPdthXBIBS1@f7E23I-^SIWYzAZ;m1($sMJK;>T?Yw+066m)mmHvTH6J) z1yKm_J$3QZmLG29S{L9ksA-cf30ey)OLJY%t*MMIls}DO7QWP(^ z+-Md`(nGijs*2KK|d$$mI(5@d7Sz{-UC>dMYVp8K`nPmyK$lH8{C&PK#NG^jU%LQ_zn>=@ z&l6t5xd4;@+XmQg%CI3N&ZQ4Q&kUfirqIi^>p$SuU$#JiK&1M0R+N@ngZS`cW4C}} zhkg;)g$ozz(SF^w)Dc&#q;}8+Knev;_T}L9zb9xxkdPRs7r+8wdn%)2Ze@AVIm6oc zk%S82qNk!Cfc2m%01yGA&Bie$F1mcDl$1thT18u1+XR5v0MwT45*KIC(nz|Z|i+aW$7Ni!*3^cB1q2=zKXL*dPPF)K?fGv1C; ziCw!6t~0Fy%Ri%6V#lWPwR7p890mqPoQi;kCR0Y75=uagqX+#Xi%kHK_e!2+<`D{7 z^lBI&%hv4XnRuU>ln&}_b#pu;VlSXJ$s12gx&{}v`Y-R&MSog~z*`CJI(q4!E;5+t zN|xa7DU&!EKIYrKA8zEx1Ta}fTR=&R1Uf7_ zukF8i5yK$S7iNZkcpnbte|S&`x01Qv?^p5i=<Tth=ca)zC+T5(2Z?%?2{ zGYKxZllr-Pl^6(9omM{%sI>Z{(1^`%Olm#{Hzt+G28tGrV-lF(=we(vb#xHzOH2Vn1iSe@Xn>4E>Hobq~o`1rU7qspVdxA!?7 z0N>v?cC8eXV36eldP&6ol*xg!Nd_eXr<#E0IBS6BS+!+#VXJshZ2EPNF{g6Wl73*m zOcrio&*??ky5SCYqNwTLC^7g42BrZbAVEuvuRe6GW5yusgLiPr->fxs_w`-nR5jW( zC}o1-I;6#;;alLbOOHn1cKnk`0}1xwS{;sC^Ynr3TUzhS{T*t-tRO$%cC82Jtc^|G zHO#BOS%a9xSoZ@LphR95qjl|5@v9rkTjFF{$($;@k&R7v`#HoR7bFtLI$2{MEvYks zd7NKR&`ImTrM3H)WG*jiE@|`w5sQU$FML0fIe1k-$orcWaN7 zm_lg~&3`b65(@b;kDd1m4?eB1b>$GZ==)W@|6A_ivy?sPvSP@ zgTlExombu!AY)sP2Y8F6_AzR}+_&D@_m7WJ0nnvSI9e>W_Lu7FF~TzFm7`wXVy3nP z8HMPqrO5axKiPKc*TdLKvPpZomTTzkutX3LlpcO$H>^r{my+9I)Du}&3l9a(Yx?ca z%XD;bFhP#CG<%zJ81GE^7ox+t=4mDrS|Vk4v9luUj^4dEUv#Jr;vWFFL_?`_l6zfe z8R(|C(e!B7IFQUdLFJwOB{M)#9001|{9BA}Yz1u&z_FBNThYS+Gb@Gm5}#B?pg1n8uE*UK;vh zU_^Wh?C7PH!qP_C@=pC-THRMMH+Rci?v{nmodf-d&I;S_ht+_e-D&p)L{vQY!s!Uw z#6&0wmB~AOvcw+U#x?-Wi@zZ%$eFO(IN9bYw;>ns#V_z!iL$lkNNej(%KohzhDoBk zWU|Y_Vs~3wS~AxfCr`%8O6}XaAPsu1ikPFz@ z*c7mG^o@vBdhpn%jVQvpcM^9DKKlUyPVg$70c|_L23uv}&gEoxn5rqz7cAIe?zM)~ zk5lq+dK#w@m3prHYPT|-z$fEkv19>`VH*vzO?n4FE~`NJHnbrm!fhjXB&_ts3;J!+ z;y(QtaLgZN;V0F6Kr3uDdkTowgq^nxKVK_urLdI*9Oi1q&5hzBGv`(m;ITMHzLb6n z;5QLqhn#?D=XjNF7&XL#Pad(dyMVl)oiq-rx(sNSFb#O@;F*3BXxGJmSh*f6TLaug z7#W4&3CenwpU-|S3*R3nTNNYQ9IaL?ChhXxJa(iGC`YN4LYMlHBP$P>y8P3x_aI8a zjw2MeZkVDI1Z1-3R{N4o#ppU_04*~%-G`skOH314EfR&BGJkBPuvP#?UBZBB6Tr&7@-Cpi5 q@*%H{FGUxV^Pqo^m+tgydKhd8`em0rX7@Xe% z-MTf~)&Kyo&cXh-GXS6n{!|3iRKN>2p+^W_zKgOy8v_8}Ys$Y0KwiNX0MG{9HNLkTr}!C=UCg;}7E)!@v^L-8`=G zk*>w)W_FWgm}yar>}|aH@IJn~X-Pb{{9-sJBpB52U;Xe$s{}fBT?h6{a+P z4p0pD&pLuF2MlG+*GrhrvXdOkek;bQODhiu-CsiyWxvGN%ZXgW`{rFvYB8 z((+9zIn1B}b3b*ssV1s_SUkq7T}s1PHrDizZYN3exxsvit&?no))`M)DkPhkk+~fM z*W20=;l+lQB#R&0lr~{7RxIcO7+`E>Wv*Dl) zgBE@HJw}A3uInW>942O4lW1;MmT)DCvpYekswe(wQk>%#-T5mrrOmj$mZ(!9kG-9*hw) zF62;$eu?M=P1^_=z;o8VFN_}%s5>It5Bp0XXp$+hhWk-rUGD_0yhM-CCyWW!+FWh% z8J*Xq^jy0pr)fS?7E*4ph)J&@5V#E|zVEa8J0Msq+f;jUOeXO25w=o?4z25C)FyDv z>8IPT)CwD%6p|uWwlC+&w)pcJ<0(k12CnHz2sq9qlE5j==`!3eHKzmibO^1xfMWq- z=@B%HpB*MLla^0(HwE}F{8=E1y`0Rsd9Nry0)eu+uFv9w(YV2w*tmBmm>I5z3eGU* zDO2KmMTZ(|Rt7pn+|)zRcA+Y&bLJJ_>MazYvQtA)owH2Q$f#yNzOkEIFmS!ZGJ2lQ zecE_WS3YNOq(A4O7~;3^>Yi>4J-(5QRH&a6(1lw%)&g>(C8_&sL;3Cgd7npwGjKH=)Nka>J~gI^;IE zs$bSfRc2}Z&j408`kh+_QM@l6Ez2H;_$|p2Yjk>WBf=!a-~fCVaGZE~qv$Zvy`-`H zQD`M=1Rq*j!R(W^QkT}XBv(|&6Q2zp5i)ETSJ1}cMfY9<8i1nT#x+Jt)RvPRMG zAgF7$uZ#n`%A?Q1O1Z;Izt&3g_#P^)A1tmB1fh=<{yz3ax59KDQ(RYOS8VagX_jdO zwYwTsL6&lCRa;s4msG7Ae*o^B2FBy_`Y>)8bDkImZQ8NhMSpE?6!)bKROq0kV(sy@ z%Nn|@%0|*dHiUXy<&;LKZ(D08BVL2(zxbjKd|FtYg0qUgrvF*{$^~zc(=}A=u^d(F zz^{6+c!*vc1!kx=gQ-O@hWk?$G~5)7w<=pF1q2ZkY4XndFwrdL&7_z^A7!JC9SQ{#eorWnQLKjSW5TD~*5; z!ET_1e~n6Jy$xj!3lZnSAXT#IVNL8v=BE@gT6Joa4I3s252?e$!8y?@O&N8_p%>+5 z5MErdK3906L0qriD5y5eRY)3q1{iNX<=E({AsJ9V!vAiY5C}6 zhEun{ALU5{j5m5NZSqlh(FJw%<@<0HU_dL*l6F#Uk(pKGs!)32^4so)#7u=6dVp19 z?NW|Xdh>WYxrTCQEq0u+q#Mb+#F!f!f;!-;WfSeH5cH4GEx0l}XO`ztBys8n|8^|~ zc8Y!;m|1whf1Q5Z8r9mLbhtiS;r3D1!}gm<*`pJ9{4#UILA7NB*T>Vjg@sqh{A(?t zb1%5(RCd8phl5c^+|#eeEV7Hm15bMD^?)R}51#petI^2`Z7qeKFaAkZzaBe&DW4Fp zIkX_s5mW~OGZvbk?b(K^v7r)=)(<|rBf=2wC>TUD=v0O&>&P*5{~L1i3OyPyP`6}1 zfES);BvHtLfPwS#XOnWy0sLiDt(`cEbJ+@T>eEq)CF;}Uvi#Yijem4S3A0&dG3&70CRV5p zz?lPf zu~I;3o+kLnp}453ZV5i*HM#%RMVc`{jd|sy{w^zld(3Lh27vQdb;)w?^HQeCufQWZ ziA#DWMfl~XQkbj3ovW?t-gaLXeUuGxd!tjfQ`LHLmj1?~K97`X3idy=Nj{czsk8Ry z5{SOe>6{UdK3ST%Ho8JS@R&(>B&A-1%6^Gki?uKI!I0XYz`X(Oq>iqxqb~kliXUrk zU7D+$l*c>DffBZP&3Sm!)YBXztHFb`U+od(twBIRY+M1*zZ~X@w*bXNqShHEJ9$Qinul_*OL(U)`@`c}gNJ0paj>jjXHe8B?b zv8?^R(*Dhp3D{UlCQUXI_KpeEvGCS&EJ7A zs<)GmN(-k_m6~09m*pXnX$YlXeY&NGg5R}F(u=mB;%AplM5gvo~Gpj1(wJl^kj!y0t){)Ncv=X#IEYF_|+2GrJNsN=((`Czp z^E~F2hum|jZ+4)=gU;A~e7vbZ;`eqsT5l2?ge20$yW)xq zmKo&FFZs@qK8Z??aKqf=W^+$uP+Q&uex09PSx0OvbS?-RIJi5MS;0Ed!fTwnkkRZX zHj|Bi=kX@dy^X|=lX&nZ50$Ww7;koCoIA44%yZTRy?-i`ER=e-n)ukAbe0?$eVBDX zI^IEl-E?|ZE8>mR{G+eB75n8x7>>Cm=J`m%Q2M4qeg+CRcoA7Myc}&!usUCW&f`lk zqy-Y+Bmnk)G^Co*q@GH^pGPFg8Cgn=(b^H{oZK_^1R5|Y?$u5%+s0832AI> zWYpG$3)$Rx>8Ev87nlv96W?1+?d^qF1QF@<;$=UPo^+fv@j#%}h(sj2INBbDeq^PP zN1lp*X^h2-9Mgr%=u;HSstmhV((Xc8ypN%T+HxD6>ouG;a$!kbI7zkaiu$EeJ-k} z$`bhksd8=~Gb(h(tZaO{p^oflOo5u{_2hQ_E4LiP&Um8+V`Xd6h zhh+?X8E#@%amTpLzK7N$%4_(M$bq+S@3kGZttU?-0DZNv4M1-c53d-58o!>AV)by4 zZdSQ?#u`pr)+|6pn2kKnL^5AAL%9Wu{+lE{7&4sB4FcL4)iDCdPmt+lD&Q0kmDqq+3P3l z4%;E>o@IDX7!z%dCmmrp9`zS|$_~?0;MC7h&z2=uVHB|X6+wW3%k}DsxITIjlk$@c zeG<8)z4D{?Y;*yCe(W!ql*B9BP3J~iKSG{9S1Ival{O7ZtL^nFAi(WhIA`ZeQb)W5 zefx@YSGMdm4%NSFNgMsto*o3S1kp@5qhm-#K>? zdg-T%-^Z54Y|K%j?vB9i-T<|KJV_RHyEB2CvXqW5eCI=CsB za|ym2+HBE#?jdUT6g$5sxs^AyyLLFSaAYNe7#I>V|G@J`sNSQW=-Os#rSx4?`Yz{p zk*n*yyB=Gt{`=`9s+)729YXAx)V~HEfS2)kT9OL_*-0xL_(+}4iIptm%$)o1mpkg3 z6Y^AO?s3s(LcQ_qojN2H=H;;V(fi%)$iabfr#+H$a)ab%11s14KhM-C!lvJ0S+wJ{|)~a zP51XQU>@B{oGtHqaW8OJ#>#6(8@(O?-rS#8i0Vm68obsmZ>JaT1RT5L{Prth0ew&# zB{^hD0Em^o98`VaEr{kSZVJ{QU}|z?)c_DuYdLtg>`_`Eo9m-8U^!b02u0Q<{i66lV;->f?db%EUo;RR9l_j>yGI_?-H zRjd#;11(#?<@6QC#V38fXLhG^{Pgy@y`(B-z*yy*v%4Db|M3pcQy*uxrUdR%ZC#x9 z0)POpLBHe0HycGjSkf(%l9FEUo?~svPOf~r1yuFHyh6=fMe~rHRso*E`W*p#z7S}Y zO!B~|@P$mP1X?xMDuGtXB#)0@L0u)#Dw$RZv`VH`PJSWNDkuNTQ=-+e0o@}Xbd&ph zv|Fjwo3`450Rc3E{lV)0MfPuZSNji1wPi!`^Ja7}7(NPrkrWydtAv$cTn59MHTnfXhw_r@X#|{+7tSfaAU|{@i~+**bJJlqvzu zCBPo}j7nI*4=hBMtR?^&z)ZH{{kpHa0^VyHuJCnBCzJwv%KzD`eO!RMZeRCxY}H=i-9I^i zHT?%5=%3B~e%Ko9vwsqSzfF{_&;GN+Pv$A!uK*>i2D5Ke%-0RZC;AM=!x8DBA>U^R z6`+HD<9WfqlO(^ouiHDJwbtQs>qYay)#}OVb@}fdjkaXY{Mj1pA}?})rMHOvBtQFS zKe_WJXI3Aq!K0Qk?(aW#RC%t;cg`-FZt+~3g3HMAH8vR7RF2{ftv>DdX0sS2@~PWs z4KpyJyzKCB8!XPAqimb$b4il@!Ri3`{JL;q=vouQk2`Y);p*)mf5khi`XqUA2<9t;(;2y9T;0qNOWE;cEv{~ zi9NuxkbBg$RD;$d;YAh!@4Gzi%6mUap;WeMX*j6VZ!>MYTlA@=Y)cO=Bgfa;U~4~f zFSPxt+oh=nA64}!7T%@i5Br0;6lXfU+4n3e9a3Jp%@nbpeB1tDc5jW~SB_wvwubyf zh5G#s16<-@6j!=hQZ)^~fH?Tr?)wrgO^4%8x24aWtgYpWRo3UetPxz`C?Cu8A0~** z9J?d*zekc@NG8f6FaLmWHL#d)kE=yXPR=?xfw#7IDc&C6aztpy2JNEK%3_VBr5WG_ zFRe%db)P^zo-#UFKwa9JyJxyO6jQP- zJvhzCG_zo1IVyJ9VQ4TXtEju>^;!9uP#KCF$T?=0*`{OK3@$SLXK2n<6WpM%CpFFJ zXy*PI4sR~wZc%$no{PL*FVgDegKrB2$lnDpf@SUxa?60Zpw>esVB0)1stk#04$+mB zDYyf$#Z=#=2NAPIZgDZToHqN+FtDc-kC!MMI&_FLN3YX&G;+)=sN?LTG8WWlFuVNB z3D3A=1|8p_e1wsAsY{!4sqM-cUH5)J=9no!uEbFE68z!icCu&N(u?*G{TvQnYc3m| zLl}W4R>lR%5*d2-zAokD%dwpuy%ySN{2)OzNnQHkkHnadH5N#L(?Lh0z)bC-2_xuG zt}faam9F@}fqC2Haej3UqQ^k+$~|tWK7M_?o;NB|QK#7k1PLtQI~ql04l~Ol8$4QB zOu`5CT#fCfbw*ozoY9X#L=O-@Iz$_XXM+ax)}4V=_^Y|Leo&>^niLU7+>4S|bzbam@&m$98&Pb-6Nfi-E<4oDioa-o@$3cxOh#^haupdP{P_ z(;)Ufup&Q}$t{||yT*A@T( literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png b/app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png new file mode 100644 index 0000000000000000000000000000000000000000..7638c9c884c1fbb7689aa0b48a6313d92d3788ad GIT binary patch literal 9733 zcmdUVc{tSV7x&mJG-$C@MpGh73X!svkx<#Q8_E_%QB=$fMTQ7ZMIsZDoh)OG5l^x< zb{RCZAPh2QnDzU9N6+>C`~Lg>T$gKR&YXKW=X}oRocsPxy!B->@f~tIAP|VS`2~b6 z1i}x#<%et&24B|i4Ku-)?YAyChe9BtyLkV6kXKnU5QqZA9C6nEUKV9a^tro3#`K@= zN4lLR#`l%A`F9BC+!x$dCjNbMGzga0Tyyhtc-^sy&xu0@msM5h+CzD@$$O>RG9gF! z&Tcyxj&m(CR*11SR)~%%ZrUibD7L+CESB=Nm|nEnM{=k+(3Q7V*hn_?12BP{u~#Do zW#+H#SS;JSD-D+}fqVO3{yTj6h%U#vJfQI$i^vCo$my|_bh|v&z_*8W*v?=aF5KCy z7KA`j=2!i;gDxMKAxc3Y#<}e{MF=Dc5ocrxS|7g?lLo#>9pG*aJpKRgKm{LjC~vRE z2{OFBM*Z9AaLLFT)>!!ov`Sg)G0z-@6JlE`SN~HvzGY6l*Onio+PD00`0zFyMI z{~g&-_4}GoP$RQn&DHVblti)!Uz2*x9?ckfIKgo(hAYySso+a-RB^i_)6Qie4queq1&GwO#x95ou zKIfQV!YCC;{YnU=^_COl6(UH__-D>8(<|8pM#CF5tA3bOMCx^sd5i(=8gJplO^_JO z2{P3s)o0gw+{T5C-k2PMYa;>fx%F)wB*t;f?=$vJR-RFzS<`BGsr<9=Pehy{5YJOx zY7oa{$)XP}UyWwTLv4$)Z4J9}+E=tP0gv>-rY>gbZl~%b|ESuk{b9Wc`f$D>4#P0R3;qj~LO`>W*ZEg7A2BL1 ziFa`@mZ$(2hd1v0AJ(8%B{20QR=k4KZ_#3G*)KSW`02KFit@8sHZtzrg=k4dFv}0 zAn6@_61Iudy6ml(%v8TQLwX!oio#V2LN3_Uv>UBY$1_b5&et&p3>2`65Mzd^0lPh} zvvejFWBx(mxIXzT3w>=nBqgCR4l_USj25@+~mahqe92L@|LIIO4Otj>F`ZXyX#0?VrMR^K&N76jatk2Q7%_B{_h zq~4M&=DQtYeIr(u>zI6i`R^&pS;XnsK-e-Lg_Q!1mfh{U8Mka;f!UTCRQ+_A00ryQ zZugwW7-fB!M+795`&lTE90zVFq4ZWehQwvI$E~|Y4D?SJ<5$eUl}zpHL2qsdb7o9# z4~JxNIh|6FH~u^H*e3YubrI+%8&(E$;}E!U_|c_R!Y>FX$K>`NwBlP7vPux5{IG2& zU`@Tv6o~s?42A;LB?VCkimeR7wb=$f?x zxOUQ&%^6rD^*G1N;$f@Iz*|!<^3>vxn2Dg0tX2|npb76(48JNr6XzrVwQ?7SgxSWQuq|~IL%g@Rq z3^mHiLUG+UTGYgT+Q_a)q*S&jg>r*_u#IAHGBv|K7>d?T;~njQuRLJcekb&!yr_PI zIkVizqtO?2Hrwr#$*e)sc~s|$M~hJ?Bk~#tC?96)!8OmbCI$X+1QVr^g=Y#p{=OV; zHcu{S>VHy~tw;$4oaNCA@!KA6ZsW$ZGDbR>`&4v*{w1ApE7pF&f9$$JC@}zSH|on6G<uam-LUdQg?J3dLvV8hI)&%NsZY zhEv7rjw>1^E9`px(bHxqg%V#*@oM>_)nft_Eh*)ETPV<9rL`_1_GwCM*ZRs(*QKi4 zB-fuAtcvFilto%x@I_~WVs(oY+VoaNrxQhMmvZbN6tJFa;8}8P@=(IY{0b&CRiCzc z(-x0nw(G1V_5^k?f+wfcMFMa*V=u)M>;moh(${Z!I~u)c+h8>I?ax`~`g^HNYjt1r zrF#lC`DQx@4K1V3nP1(EE1xjK)LWlu42o=D?wRv*Q%TmZRCsvMg`G%UY@-UbJxZ=2 zIw(Bo5(E0xMdqP*3WzHbf*w4&81&#QN+R$s zR1`wFI^!|k!%~lgliQSBM?~arhHt-jf~{@Lx+pAXaYWbs<&u;H*P+0`NjVxSD0j%c zxJ_u&_5N=) zk6~i`gcW4F)cD2{w-nwzJwQj3LTKnJ?~i>80#D8#%sgYaa^@22hOitwL(!8K%WRMR zUOxBGYbB2xnEa1vGI0!Mubp5Sg$&LDLMhd<{@BB5kce7tR8D;f=l7R5fL4(nhjS@4 zdQ-^9Xt6K=u=H^;ZN%^NsQiLOs7yjr!R3h=&QQdU?Q8vmza+(u1T38q6gJF5=?IA1 zEGT-;nE!|2iH2~UsmNme_od~ADHjedid-xMLP>sF{Sx`^{8iU@$?^I=rxsWv%p_?7 zvL!~?3p6_+9_{>9a^r6k@TlRXQ12Lfz+fnx^Ji^(h_WDng?BQed&MJdc}C{mu>^Y^ z8cz-X1Gq(4M~2s^-3y54PUrY*eqr^_!iEhqqaQ0zN%h|@=ceSGTGXF|ZgjN{3;aq| z+FJ@mFm`YIU=S`RMWYrAj-ImDKct#AlyGSFB;$yMsFzcSA`LR-;?f z^+~mg^3`4Uq%{4^KZ#4l8<10%9gNbRE+Z{m$1bh)cApV(tqOUnkB9GD%6Jhj&Lw8M z-_NXH?y9O{A$=|p!+qKpG|T}KvSx)^BL8X_AxjbH%?p?f z_k~e`d}>PtFQqPaFdNj>H9fX`wcFBo>|bwTF4X{;PG8>lYv9Xg;kfEAXMg*gHM_a0 zBvKO5qJgHcMhH3+HEa6gwalY26X4x?DbVofP4~J^4(;xO$n*vKvW@O%s+l54U4=z%sTcAy{e^BZ zFjcfEo37#h{@l*31U(97yWzUx!-FkK95ju*vjuL%R(5Cg?<U*U=~3gKSSMRU4am<;Ut1NyLXwuaOu64~wBRbADaE|C z2l0#psw2Qt`*NaK=M63&MN8JduuUCI)m^_Ej8TOv7K@IUyQ}IEq_|99=&+->Vd(TP z{EAFv#o|9NHj04e!Wd^lz?Fji<)|Y9;tutS(*w^&;#gl6+^LezjceaN)(sTF0#etl zTt?fyoVsOjLT$0JDDNdKpFzAG`ajp@QToz1nz zx|Pwfd-uo0XhvrgQ~KPu3QBVHW7S?MiLNJn?F1%{(wIM7u>>n?TRfyw!TEX&^&06$ zC2z{v{itF5B%UIiB72hzN@H2#fkzWbKc@3mh#fONvU|F{*s?`F<(BcH9gsvx67?0jw z|Dd!pfhu=;vwdQm)D)C3eM@GycP=>1vy z#%7#VyCnz3$#dU)`D69pe}Tybc}a5r zg?x?3?Nm{^kqW83!|qb1V*ookP5J#Ag$%SZtwT(H~8XNDd-dy^Z*OOasz$UczSG;Rg30g}N8})NMrD5w#jT zbs%ne&{e>(jG4pCIfoGHVv?`p@Wyw%LxwKWa)5k$YPizC8Xm%?rv8g zp6IHeVsi|V6RUI+*x)W6lV=N_rU=Ah6JAtuki&+-(S-=`$So~&Ob?YQG(BGKIt-P9 zt9W$W&qICVw}}tq+ji-IYSmI=S@!ydVo@WS_9zCs<0Vbt#jE{gpaBEe2YlPy&s=^> z>HP@fXv`P>hQSf`U;h@VV-iX8(oy}iAD>3cZH@G?h11SP?mVy9#0Ys(lawq%ldLum z;+Y5KdjDfjcQ{%jwjL3hGcOkcbrTwqNKEUL#oFr_Ds?dij1R!uEWUs-W6t34)$6(~CY!c}+3UZW5{!8k$57n+&KzcM$7&eWX*d-Y_tVy;bz z18YfW_)|Tk2G1^`&ln@}zij+DXM9+rR>-sGIqDbJ&(ZXjv4l;A7OiEdhKMwFxFOoQ z&}5i{J}zOy0FM@Vq&bZ@Dz!q>@n*}mMlx9C=u)7T&JZn3xX#yEp?C3B;655u7_Nfq zm?aDwV)z_Rs6(d0F;_U&3c1>vU&whVE`RY=7sy_X4<HcA_Qx6i-wNtH0=7N|zPW&ToO#5IKGvf#@C2lc$Nsromr!=$!)- z^_49WB9Ds9W_lS{eo85@`9gnPj1m?w*8s08fIf=G0!$e3+}>ePDFFA;cu=(U@x+m0 zm>}Z0hr=Sd{I8;_AIg-YppYwA&1kA2dMf-D@*Ecc$}tiWHX#`X=E4KMd+UE# zBT#<0Ma5P(BrA5f8PXe3YsMS7ewEL7utqv6x+ty?#*cXJ%iaMoAM${BywW+#kx=se3){+gAwtcV%NjWVV-Ro*Qm$<9D(PkA!>z#(Kf8| z$G@=#t<|TtIxt1}Bo(D$f-yg@Ye0y#Z2K+q1Hw?jqgyLL{8~!TJjeaoGe<}tkG9Hu zWfcB#hn_J@O<3;ou|PRhtydjM_Ub+9u<^-1ZEt;>7Ua@6WOmS4j}N4e4Gd6;=R_Xk6o4*JT5qW!vi6fcF=2 zrXJxOXI}=^Ur=*2SZB0OW)8>+V3FT}L{RLq5aKj43)4MD=%{BqQ-ZDGx#xYrvy27Fge<0Z1Biuzfp{8HG?xVK29RXAP^_i1Uh~ho!rU{f6sq`e4H~d()$bQ zI~qK|^*sbnsAqr&Z{M)Yr2H$}LAZ)@2P0iG z9k{pE4SW-D1)c!G;1Q5SRzM)~iOjhqDtVh<1)Y%`iDg{9d9-49a;;QSGIuEEjBeiuu3`dGG-@-|7?U54eFk=b~9 zpCtkjk-_lCREVc9HI}5iUsBR^B=3d1-i3(sUb~Hk zKRaO@1$E^>X*$6n;Z9}10Pa)R);8CF~N14yG~(}>H6(K-EM{std4^qBEgo$ z4DTlwW`oa0wk31Hz#Rt7S93Zj;!o5O%gZ$z$2UGUsR6WBB0Ra1f58 z!8fiy`+@h{ALa={Qr?hc!#RKSj^RTi0ZE&fadrSjFYZ968+R9NUkny&!FoA8Tl-D- zI`Q;}?k!hxj!<}CbZ1s_g(>&r9J}1Os+4_?K-FxTfV1Cvil0Rwj(T&B7^)xV2R*J_&Am_C_YjL6>|$buvoT#IPQXdpzGy7= zpYE@d9!E5IuV!4O@MMEM!)um!KhLVFMuIg9%!XWTtx@27#LgpNuG2`m5J}1W zjLMequ*?tVBlaFC$UnyN`$Bkw5&%=Q4P>!EV*5|~o7~$pQ7Z)Ex*unyX3i@_6ND`C z9vp6=PadnX-#MBc-{l0JFt)+^3NbVBSY=vbk5Q?C^t03v>P7(a7Cd*YHqSuSQYq3ViR$rpsWXN7$KtLNdj_{7?J1%l0X(O?)uS{k1%jgML5rwo^jJkW zygSU6*VE4V{I)N{6{5VrC97UL5H&`#)AQ|$ey!LOK%PJZeRgo;22t)ipLRCDRQ%t8 zv#>!>ZC6VnNjeQg(1f!N@RI&P?e(gu!+*nM(=kpF9%+~H*+4!$c1j2K5eFjVYF9nm zfJCjjb5R{b_;0qoEBrF|d1Qj-?P2oi4?RfU8(XUMb)*x|Mx_iF^3km!%`4jfnRD1s z)N{9eKuFDhQ%K;1Pg%IQcz>wm`cOnKf)?M@SZu=k`CONs8!JM3eq)+}rZgE88Yo$+ zhTabplzxfDUa0LOmx6+xQ$}_GFGjy6yl>MXCzp8>aHZ4EomM>>7g@=&mH(%UlJOlBuu9;qUorkL2 z8zqT-U?e^diUwEr|Ha)Ehox2|Pt;QiocV9u2#WS?7k=*AZ-<+}M2&_v$YgYj4(3kE z_{*3cM2GLFQ3dKF=q^n(Kz8b2lKnyC!*hFo*_O>=inf3rP2^<*C}VI%(z_1wvO)ds zeFK;{=pH7+nWS|>Z|0gbO+z0{%BBzRi!zXi4TT=`Ka|{ZNs1V(L0vtG1|fhqTmxdK^Fd_X3n#!Gk0ABd?-@F z`z?UWLKUHstH;?Qh;HB+UF;&5LB#c7L2wR5@IHulsZD2FdTc_3e?*p}Oh7V_lT8O) zBIq-FIv5!wT|r4n#T1(B#j=&%l!iD*?@?R<+78tEt@XufDEt1sJ>R4*AP{-4XaarZ zD}5yD&WgdwWu+qWevs2=%ZIY>evR&BL>#I=i^UekRIr1?Lcs8~@3ddw`2D#D=8@d* z#6HYhgCEBedw<#e| zL1ihunpe)i#b3bydAGg6n=75a(8eZWhA4C=n&V0$oRm5Dvle`XB7j?4+Jz6T49 zly?>`EWB=ip@FOQ8zo`6OpX4Q+hEZp$}wM*1C4b3vwVGEKPv~~igg5Rm!^80$R7pJ z@5eNCS9ND@9wO<2DJG7qb?w`*p4E3Xg@djJx`7bNn9HJ)bA9@=BK*SB*$DGwG9%^;_U(>T5+|FB~F# zbOOljPw@CTKB+zEpWZhNV?S@(C7A@y-3KzmT7|VU^f}3CKbga6tU*4xPyovqF}It>enTKKc-^q2B2JEbhm)kHXF;Kjxm? zlTK&?dy_!iNNZt0QO~04D5kHT9o@@RC-FMBr0!EVKOM~1QyAwFX8cw||BP?XOqtru zz>Aq5Dq3@60hmxJ$)p=Ic_Zy--m*7}L9n6=pf}>MiPwWxFoEFng(RBpVS2PFIxhw)DdPHXY3l|`!w0h(1$Ht#{_7{0;DRj8brfN(% zx4O7?<;RS=7WgDBAj}o5Uyufr+LA+JD_zmMUtQxEU0D!7_L17n_CeW>V5`0cbqrvf vQ9X%yT10!@wu}D1{ClGR*WTY84FO - + diff --git a/app/src/main/res/drawable/ic_calc_foreground.xml b/app/src/main/res/drawable/ic_calc_foreground.xml index 41c843b..4f7b0bc 100644 --- a/app/src/main/res/drawable/ic_calc_foreground.xml +++ b/app/src/main/res/drawable/ic_calc_foreground.xml @@ -1,38 +1,7 @@ - - - - - - - - - - - - - - - + + + + diff --git a/app/src/main/res/drawable/ic_notes_background.xml b/app/src/main/res/drawable/ic_notes_background.xml index fdeeb63..4a213bb 100644 --- a/app/src/main/res/drawable/ic_notes_background.xml +++ b/app/src/main/res/drawable/ic_notes_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_notes_foreground.xml b/app/src/main/res/drawable/ic_notes_foreground.xml index 6acf37d..311c95a 100644 --- a/app/src/main/res/drawable/ic_notes_foreground.xml +++ b/app/src/main/res/drawable/ic_notes_foreground.xml @@ -1,52 +1,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + diff --git a/app/src/main/res/drawable/ic_weather_background.xml b/app/src/main/res/drawable/ic_weather_background.xml index 275abac..4a213bb 100644 --- a/app/src/main/res/drawable/ic_weather_background.xml +++ b/app/src/main/res/drawable/ic_weather_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_weather_foreground.xml b/app/src/main/res/drawable/ic_weather_foreground.xml index 7bd9a2d..14f4d2b 100644 --- a/app/src/main/res/drawable/ic_weather_foreground.xml +++ b/app/src/main/res/drawable/ic_weather_foreground.xml @@ -1,32 +1,7 @@ - - - - - - - - - - - - - - - - + + + + From 4396611355fe6214afad1ea71b07465bb8a3ca81 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 14 Apr 2026 13:53:01 +0500 Subject: [PATCH 30/32] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=B5=D1=80=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D1=85:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B2=20=D1=87=D0=B0=D1=82,=20smooth=20UI?= =?UTF-8?q?,=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=B1=D0=B0=D0=B3=20=D1=81=20au?= =?UTF-8?q?to-play=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 6 + .../messenger/ui/chats/ChatDetailScreen.kt | 45 +++++- .../messenger/ui/chats/ChatsListScreen.kt | 130 +++++++++-------- .../chats/components/AttachmentComponents.kt | 138 +++++++++++------- .../ui/chats/input/ChatDetailInput.kt | 58 +++++--- 5 files changed, 243 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index b681248..e6d1bca 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -58,6 +58,12 @@ import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.DeviceConfirmScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen +import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer +import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.ui.Alignment import com.rosetta.messenger.ui.chats.ConnectionLogsScreen import com.rosetta.messenger.ui.chats.GroupInfoScreen import com.rosetta.messenger.ui.chats.GroupSetupScreen diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 0633e54..5b14678 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.components.* +import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.input.* @@ -441,11 +442,29 @@ fun ChatDetailScreen( showEmojiPicker = false } - // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager + // 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat) val hideKeyboardAndBack: () -> Unit = { hideInputOverlays() onBack() } + // 🔥 Поведение как у нативного Android back: + // сначала закрываем IME/emoji, и только следующим back выходим из чата. + val handleBackWithInputPriority: () -> Unit = { + val imeVisible = + androidx.core.view.ViewCompat.getRootWindowInsets(view) + ?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true + val hasInputOverlay = + showEmojiPicker || + coordinator.isEmojiBoxVisible || + coordinator.isKeyboardVisible || + imeVisible + + if (hasInputOverlay) { + hideInputOverlays() + } else { + onBack() + } + } // Определяем это Saved Messages или обычный чат val isSavedMessages = user.publicKey == currentUserPublicKey @@ -1344,7 +1363,7 @@ fun ChatDetailScreen( if (isInChatSearchMode) { closeInChatSearch() } else { - hideKeyboardAndBack() + handleBackWithInputPriority() } } @@ -1859,7 +1878,7 @@ fun ChatDetailScreen( Box { IconButton( onClick = - hideKeyboardAndBack, + handleBackWithInputPriority, modifier = Modifier.size( 40.dp @@ -2305,6 +2324,26 @@ fun ChatDetailScreen( avatarRepository = avatarRepository ) } + // Voice mini player — shown right under the chat header when audio is playing + val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() + if (!playingVoiceAttachmentId.isNullOrBlank()) { + val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() + val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() + val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() + val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() + val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" } + val time = playingVoiceTimeLabel.trim() + val voiceTitle = if (time.isBlank()) sender else "$sender at $time" + VoiceTopMiniPlayer( + title = voiceTitle, + isDarkTheme = isDarkTheme, + isPlaying = isVoicePlaybackRunning, + speed = voicePlaybackSpeed, + onTogglePlay = { VoicePlaybackCoordinator.toggleCurrentPlayback() }, + onCycleSpeed = { VoicePlaybackCoordinator.cycleSpeed() }, + onClose = { VoicePlaybackCoordinator.stop() } + ) + } } // Закрытие Column topBar }, containerColor = backgroundColor, // Фон всего чата diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 0a94abc..ebcbda7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.Immutable @@ -5059,7 +5063,7 @@ private fun VoicePlaybackIndicatorSmall( } } -private fun formatVoiceSpeedLabel(speed: Float): String { +fun formatVoiceSpeedLabel(speed: Float): String { val normalized = (speed * 10f).roundToInt() / 10f return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) { "${normalized.toInt()}x" @@ -5069,7 +5073,7 @@ private fun formatVoiceSpeedLabel(speed: Float): String { } @Composable -private fun VoiceTopMiniPlayer( +fun VoiceTopMiniPlayer( title: String, isDarkTheme: Boolean, isPlaying: Boolean, @@ -5078,74 +5082,80 @@ private fun VoiceTopMiniPlayer( onCycleSpeed: () -> Unit, onClose: () -> Unit ) { - val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF) - val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC) - val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047) - val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A) + // Match overall screen surface aesthetic — neutral elevated surface, no blue accent + val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White + val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA) + val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) - Row( - modifier = - Modifier.fillMaxWidth() - .height(36.dp) - .background(containerColor) + Column(modifier = Modifier.fillMaxWidth().background(containerColor)) { + Row( + modifier = Modifier.fillMaxWidth() + .height(40.dp) .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onTogglePlay, - modifier = Modifier.size(28.dp) + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = - if (isPlaying) TablerIcons.PlayerPause - else TablerIcons.PlayerPlay, - contentDescription = if (isPlaying) "Pause voice" else "Play voice", - tint = accentColor, - modifier = Modifier.size(18.dp) + IconButton( + onClick = onTogglePlay, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = + if (isPlaying) Icons.Default.Pause + else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause voice" else "Play voice", + tint = primaryIconColor, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + AppleEmojiText( + text = title, + fontSize = 14.sp, + color = textColor, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f), + enableLinks = false, + minHeightMultiplier = 1f ) - } - AppleEmojiText( - text = title, - fontSize = 14.sp, - color = textColor, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f), - enableLinks = false, - minHeightMultiplier = 1f - ) + Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = - Modifier.clip(RoundedCornerShape(8.dp)) - .border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + Box( + modifier = Modifier.clip(RoundedCornerShape(8.dp)) + .border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp)) .clickable { onCycleSpeed() } - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = formatVoiceSpeedLabel(speed), - color = accentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - } + .padding(horizontal = 8.dp, vertical = 3.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = formatVoiceSpeedLabel(speed), + color = secondaryColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(4.dp)) - IconButton( - onClick = onClose, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = TablerIcons.X, - contentDescription = "Close voice", - tint = secondaryColor, - modifier = Modifier.size(18.dp) - ) + IconButton( + onClick = onClose, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close voice", + tint = secondaryColor, + modifier = Modifier.size(20.dp) + ) + } } + Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 6c6ef99..3e53b9f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -85,6 +85,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit @@ -144,12 +145,24 @@ private fun decodeBase64Payload(data: String): ByteArray? { private fun decodeHexPayload(data: String): ByteArray? { val raw = data.trim().removePrefix("0x") if (raw.isBlank() || raw.length % 2 != 0) return null - if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null - return runCatching { - ByteArray(raw.length / 2) { index -> - raw.substring(index * 2, index * 2 + 2).toInt(16).toByte() + fun nibble(ch: Char): Int = + when (ch) { + in '0'..'9' -> ch.code - '0'.code + in 'a'..'f' -> ch.code - 'a'.code + 10 + in 'A'..'F' -> ch.code - 'A'.code + 10 + else -> -1 } - }.getOrNull() + val out = ByteArray(raw.length / 2) + var outIndex = 0 + var index = 0 + while (index < raw.length) { + val hi = nibble(raw[index]) + val lo = nibble(raw[index + 1]) + if (hi < 0 || lo < 0) return null + out[outIndex++] = ((hi shl 4) or lo).toByte() + index += 2 + } + return out } private fun decodeVoicePayload(data: String): ByteArray? { @@ -280,7 +293,12 @@ object VoicePlaybackCoordinator { val normalized = speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() _playbackSpeed.value = normalized - player?.let { applyPlaybackSpeed(it) } + // Only apply to the player if it's currently playing — otherwise setting + // playbackParams auto-resumes a paused MediaPlayer (Android quirk). + // The new speed will be applied on the next resume() call. + if (_isPlaying.value) { + player?.let { applyPlaybackSpeed(it) } + } } private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) { @@ -2125,9 +2143,10 @@ private fun ensureVoiceAudioFile( attachmentId: String, payload: String ): File? { - val bytes = decodeVoicePayload(payload) ?: return null val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() } val file = File(directory, "$attachmentId.webm") + if (file.exists() && file.length() > 0L) return file + val bytes = decodeVoicePayload(payload) ?: return null runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null return file } @@ -2149,10 +2168,16 @@ private fun VoiceAttachment( val context = LocalContext.current val scope = rememberCoroutineScope() val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() - val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState() - val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState() - val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState() val isActiveTrack = activeAttachmentId == attachment.id + val playbackPositionMs by + (if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0)) + .collectAsState(initial = 0) + val playbackDurationMs by + (if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0)) + .collectAsState(initial = 0) + val playbackIsPlaying by + (if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false)) + .collectAsState(initial = false) val isPlaying = isActiveTrack && playbackIsPlaying val (previewDurationSecRaw, previewWavesRaw) = @@ -2167,12 +2192,19 @@ private fun VoiceAttachment( var payload by remember(attachment.id, attachment.blob) { - mutableStateOf(attachment.blob.trim()) + mutableStateOf(attachment.blob) } + val cachedAudioPath = + remember(attachment.id) { + val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm") + file.takeIf { it.exists() && it.length() > 0L }?.absolutePath + } + var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) } var downloadStatus by remember(attachment.id, attachment.blob, attachment.transportTag) { mutableStateOf( when { + cachedAudioPath != null -> DownloadStatus.DOWNLOADED attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED else -> DownloadStatus.ERROR @@ -2180,7 +2212,6 @@ private fun VoiceAttachment( ) } var errorText by remember { mutableStateOf("") } - var audioFilePath by remember(attachment.id) { mutableStateOf(null) } val effectiveDurationSec = remember(isPlaying, playbackDurationMs, previewDurationSec) { @@ -2217,24 +2248,6 @@ private fun VoiceAttachment( .getOrDefault("") } - LaunchedEffect(payload, attachment.id) { - if (payload.isBlank()) return@LaunchedEffect - val prepared = ensureVoiceAudioFile(context, attachment.id, payload) - if (prepared != null) { - audioFilePath = prepared.absolutePath - if (downloadStatus != DownloadStatus.DOWNLOADING && - downloadStatus != DownloadStatus.DECRYPTING - ) { - downloadStatus = DownloadStatus.DOWNLOADED - } - if (errorText.isNotBlank()) errorText = "" - } else { - audioFilePath = null - downloadStatus = DownloadStatus.ERROR - if (errorText.isBlank()) errorText = "Cannot decode voice" - } - } - val triggerDownload: () -> Unit = download@{ if (attachment.transportTag.isBlank()) { downloadStatus = DownloadStatus.ERROR @@ -2258,17 +2271,30 @@ private fun VoiceAttachment( errorText = "Failed to decrypt" return@launch } + downloadStatus = DownloadStatus.DECRYPTING + val prepared = + withContext(Dispatchers.IO) { + ensureVoiceAudioFile(context, attachment.id, decrypted) + } + if (prepared == null) { + downloadStatus = DownloadStatus.ERROR + errorText = "Cannot decode voice" + return@launch + } + audioFilePath = prepared.absolutePath val saved = - runCatching { - AttachmentFileManager.saveAttachment( - context = context, - blob = decrypted, - attachmentId = attachment.id, - publicKey = senderPublicKey, - privateKey = privateKey - ) - } - .getOrDefault(false) + withContext(Dispatchers.IO) { + runCatching { + AttachmentFileManager.saveAttachment( + context = context, + blob = decrypted, + attachmentId = attachment.id, + publicKey = senderPublicKey, + privateKey = privateKey + ) + } + .getOrDefault(false) + } payload = decrypted if (!saved) { // Не блокируем UI, но оставляем маркер в логе. @@ -2286,22 +2312,30 @@ private fun VoiceAttachment( val file = audioFilePath?.let { File(it) } if (file == null || !file.exists()) { if (payload.isNotBlank()) { - val prepared = ensureVoiceAudioFile(context, attachment.id, payload) + scope.launch { + downloadStatus = DownloadStatus.DECRYPTING + errorText = "" + val prepared = + withContext(Dispatchers.IO) { + ensureVoiceAudioFile(context, attachment.id, payload) + } if (prepared != null) { audioFilePath = prepared.absolutePath - VoicePlaybackCoordinator.toggle( - attachmentId = attachment.id, - sourceFile = prepared, - dialogKey = dialogPublicKey, - senderLabel = playbackSenderLabel, - playedAtLabel = playbackTimeLabel - ) { message -> + downloadStatus = DownloadStatus.DOWNLOADED + VoicePlaybackCoordinator.toggle( + attachmentId = attachment.id, + sourceFile = prepared, + dialogKey = dialogPublicKey, + senderLabel = playbackSenderLabel, + playedAtLabel = playbackTimeLabel + ) { message -> + downloadStatus = DownloadStatus.ERROR + errorText = message + } + } else { downloadStatus = DownloadStatus.ERROR - errorText = message + errorText = "Cannot decode voice" } - } else { - downloadStatus = DownloadStatus.ERROR - errorText = "Cannot decode voice" } } else { triggerDownload() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index cad9e45..2acacdf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Videocam import androidx.compose.animation.* @@ -2387,7 +2388,7 @@ fun MessageInputBar( .background(recordingPanelColor), verticalAlignment = Alignment.CenterVertically ) { - // Delete button — Telegram: 44×44dp, Lottie trash icon + // Delete button — Telegram-style trash action Box( modifier = Modifier .size(recordingActionButtonBaseSize) @@ -2401,7 +2402,7 @@ fun MessageInputBar( contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.Close, + imageVector = Icons.Default.Delete, contentDescription = "Delete recording", tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D), modifier = Modifier.size(20.dp) @@ -2419,38 +2420,57 @@ fun MessageInputBar( } } else { // ── RECORDING panel ── - // [dot][timer] [◀ Slide to cancel] + // [attach-slot => dot/trash morph][timer] [◀ Slide to cancel] + val dragCancelProgress = + ((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx) + .coerceIn(0f, 1f) + val leftDeleteProgress = + maxOf( + cancelAnimProgress, + FastOutSlowInEasing.transform( + (dragCancelProgress * 0.85f).coerceIn(0f, 1f) + ) + ) Row( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(24.dp)) .background(recordingPanelColor) - .padding(start = 13.dp), + .padding(start = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - // Blink dot + timer - Row( + // Left slot (same anchor as attach icon in normal input): + // morphs from recording dot to trash while user cancels. + Box( modifier = Modifier + .size(40.dp) .graphicsLayer { - alpha = recordUiAlpha * (1f - cancelAnimProgress) + alpha = recordUiAlpha translationX = with(density) { recordUiShift.toPx() } }, - verticalAlignment = Alignment.CenterVertically + contentAlignment = Alignment.Center ) { TelegramVoiceDeleteIndicator( - cancelProgress = cancelAnimProgress, - isDarkTheme = isDarkTheme - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = formatVoiceRecordTimer(voiceElapsedMs), - color = recordingTextColor, - fontSize = 15.sp, - fontWeight = FontWeight.Bold + cancelProgress = leftDeleteProgress, + isDarkTheme = isDarkTheme, + modifier = Modifier.size(28.dp) ) } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(2.dp)) + + Text( + text = formatVoiceRecordTimer(voiceElapsedMs), + color = recordingTextColor, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.graphicsLayer { + alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f) + translationX = with(density) { recordUiShift.toPx() } + } + ) + + Spacer(modifier = Modifier.width(10.dp)) // Slide to cancel SlideToCancel( @@ -2459,7 +2479,7 @@ fun MessageInputBar( modifier = Modifier .weight(1f) .graphicsLayer { - alpha = recordUiAlpha * (1f - cancelAnimProgress) + alpha = recordUiAlpha * (1f - leftDeleteProgress) translationX = with(density) { recordUiShift.toPx() } } ) From 060d0cbd127213dd29a0c2a200987a2e413219c3 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 15 Apr 2026 02:29:08 +0500 Subject: [PATCH 31/32] =?UTF-8?q?=D0=A7=D0=B0=D1=82/=D0=B7=D0=B2=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8/=D0=BA=D0=BE=D0=BD=D0=BD=D0=B5=D0=BA=D1=82:?= =?UTF-8?q?=20Telegram-like=20UX=20=D0=B8=20=D1=80=D1=8F=D0=B4=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 62 ++++- .../messenger/data/MessageRepository.kt | 6 + .../rosetta/messenger/network/CallManager.kt | 31 ++- .../com/rosetta/messenger/network/Protocol.kt | 41 ++- .../messenger/ui/chats/ChatDetailScreen.kt | 40 ++- .../messenger/ui/chats/ChatViewModel.kt | 6 + .../messenger/ui/chats/ChatsListScreen.kt | 161 +++++++++--- .../messenger/ui/chats/ChatsListViewModel.kt | 92 ++++++- .../messenger/ui/chats/GroupInfoScreen.kt | 76 ++++-- .../chats/components/AttachmentComponents.kt | 145 ++++++++++- .../chats/components/ChatDetailComponents.kt | 26 +- .../chats/components/TextSelectionHelper.kt | 52 ++-- .../ui/chats/input/ChatDetailInput.kt | 238 +++++++++--------- .../ui/components/SwipeBackContainer.kt | 28 +++ .../res/raw/chat_audio_record_delete_2.json | 1 + 15 files changed, 767 insertions(+), 238 deletions(-) create mode 100644 app/src/main/res/raw/chat_audio_record_delete_2.json diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index e6d1bca..5a101c6 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -56,6 +56,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.DeviceConfirmScreen +import com.rosetta.messenger.ui.auth.startAuthHandshakeFast import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer @@ -91,6 +92,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class MainActivity : FragmentActivity() { @@ -302,16 +304,57 @@ class MainActivity : FragmentActivity() { startInCreateMode = startCreateAccountFlow, onAuthComplete = { account -> startCreateAccountFlow = false - currentAccount = account - cacheSessionAccount(account) + val normalizedAccount = + account?.let { + val normalizedName = + resolveAccountDisplayName( + it.publicKey, + it.name, + null + ) + if (it.name == normalizedName) it + else it.copy(name = normalizedName) + } + currentAccount = normalizedAccount + cacheSessionAccount(normalizedAccount) hasExistingAccount = true // Save as last logged account - account?.let { + normalizedAccount?.let { accountManager.setLastLoggedPublicKey(it.publicKey) } + // Первый запуск после регистрации: + // дополнительно перезапускаем auth/connect, чтобы не оставаться + // в "залипшем CONNECTING" до ручного рестарта приложения. + normalizedAccount?.let { authAccount -> + startAuthHandshakeFast( + authAccount.publicKey, + authAccount.privateKeyHash + ) + scope.launch { + repeat(3) { attempt -> + if (ProtocolManager.isAuthenticated()) return@launch + delay(2000L * (attempt + 1)) + if (ProtocolManager.isAuthenticated()) return@launch + ProtocolManager.reconnectNowIfNeeded( + "post_auth_complete_retry_${attempt + 1}" + ) + startAuthHandshakeFast( + authAccount.publicKey, + authAccount.privateKeyHash + ) + } + } + } + // Reload accounts list scope.launch { + normalizedAccount?.let { + // Синхронно помечаем текущий аккаунт активным в DataStore. + runCatching { + accountManager.setCurrentAccount(it.publicKey) + } + } val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } } @@ -1492,9 +1535,18 @@ fun MainScreen( } }.collectAsState(initial = 0) + var chatSelectionActive by remember { mutableStateOf(false) } + val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) } + SwipeBackContainer( isVisible = selectedUser != null, onBack = { popChatAndChildren() }, + onInterceptSwipeBack = { + if (chatSelectionActive) { + chatClearSelectionRef.value() + true + } else false + }, isDarkTheme = isDarkTheme, layer = 1, swipeEnabled = !isChatSwipeLocked, @@ -1539,7 +1591,9 @@ fun MainScreen( avatarRepository = avatarRepository, onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }, isCallActive = callUiState.isVisible, - onOpenCallOverlay = { isCallOverlayExpanded = true } + onOpenCallOverlay = { isCallOverlayExpanded = true }, + onSelectionModeChange = { chatSelectionActive = it }, + registerClearSelection = { fn -> chatClearSelectionRef.value = fn } ) } } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 18366d9..b06b8ba 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -598,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) { // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages dialogDao.updateDialogFromMessages(account, toPublicKey) + // Notify listeners (ChatViewModel) that a new message was persisted + // so the chat UI reloads from DB. Without this, messages produced by + // non-input flows (e.g. CallManager's missed-call attachment) only + // appear after the user re-enters the chat. + _newMessageEvents.tryEmit(dialogKey) + // 📁 Для saved messages - гарантируем создание/обновление dialog if (isSavedMessages) { val existing = dialogDao.getDialog(account, account) diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index 5bf11cc..ef90b1e 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -95,7 +95,11 @@ object CallManager { private const val TAIL_LINES = 300 private const val PROTOCOL_LOG_TAIL_LINES = 180 private const val MAX_LOG_PREFIX = 180 - private const val INCOMING_RING_TIMEOUT_MS = 45_000L + // Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are + // slightly larger so the server's RINGING_TIMEOUT signal takes precedence when + // the network is healthy; local jobs are a fallback when the signal is lost. + private const val INCOMING_RING_TIMEOUT_MS = 35_000L + private const val OUTGOING_RING_TIMEOUT_MS = 35_000L private const val CONNECTING_TIMEOUT_MS = 30_000L private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -127,6 +131,7 @@ object CallManager { private var protocolStateJob: Job? = null private var disconnectResetJob: Job? = null private var incomingRingTimeoutJob: Job? = null + private var outgoingRingTimeoutJob: Job? = null private var connectingTimeoutJob: Job? = null private var signalWaiter: ((Packet) -> Unit)? = null @@ -290,6 +295,18 @@ object CallManager { ) breadcrumbState("startOutgoingCall") appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) } + + // Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost, + // stop ringing after the same window the server uses (~30s + small buffer). + outgoingRingTimeoutJob?.cancel() + outgoingRingTimeoutJob = scope.launch { + delay(OUTGOING_RING_TIMEOUT_MS) + val snap = _state.value + if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) { + breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset") + resetSession(reason = "No answer", notifyPeer = true) + } + } return CallActionResult.STARTED } @@ -551,6 +568,9 @@ object CallManager { breadcrumb("SIG: ACCEPT ignored — role=$role") return } + // Callee answered before timeout — cancel outgoing ring timer + outgoingRingTimeoutJob?.cancel() + outgoingRingTimeoutJob = null if (localPrivateKey == null || localPublicKey == null) { breadcrumb("SIG: ACCEPT — generating local session keys") generateSessionKeys() @@ -1033,9 +1053,14 @@ object CallManager { preview = durationSec.toString() ) + // Capture role synchronously before the coroutine launches, because + // resetSession() sets role = null right after calling this function — + // otherwise the async check below would fall through to the callee branch. + val capturedRole = role + scope.launch { runCatching { - if (role == CallRole.CALLER) { + if (capturedRole == CallRole.CALLER) { // CALLER: send call attachment as a message (peer will receive it) MessageRepository.getInstance(context).sendMessage( toPublicKey = peerPublicKey, @@ -1082,6 +1107,8 @@ object CallManager { disconnectResetJob = null incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob = null + outgoingRingTimeoutJob?.cancel() + outgoingRingTimeoutJob = null // Play end call sound, then stop all if (wasActive) { appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) } diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index f0a7813..65943f4 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -35,6 +35,7 @@ class Protocol( private const val TAG = "RosettaProtocol" private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве) private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds + private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each) private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15 private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L @@ -182,6 +183,7 @@ class Protocol( private var lastSuccessfulConnection = 0L private var reconnectJob: Job? = null // Для отмены запланированных переподключений private var isConnecting = false // Флаг для защиты от одновременных подключений + private var connectingSinceMs = 0L private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -385,6 +387,7 @@ class Protocol( */ fun connect() { val currentState = _state.value + val now = System.currentTimeMillis() log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting") // КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся! @@ -403,10 +406,20 @@ class Protocol( return } - // КРИТИЧНО: проверяем флаг isConnecting, а не только состояние + // КРИТИЧНО: проверяем флаг isConnecting, а не только состояние. + // Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения. if (isConnecting || currentState == ProtocolState.CONNECTING) { - log("⚠️ Already connecting, skipping... (preventing duplicate connect)") - return + val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L + if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) { + log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)") + return + } + log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset") + isConnecting = false + connectingSinceMs = 0L + runCatching { webSocket?.cancel() } + webSocket = null + setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)") } val networkReady = isNetworkAvailable?.invoke() ?: true @@ -424,6 +437,7 @@ class Protocol( // Устанавливаем флаг ПЕРЕД любыми операциями isConnecting = true + connectingSinceMs = now reconnectAttempts++ log("📊 RECONNECT ATTEMPT #$reconnectAttempts") @@ -455,6 +469,7 @@ class Protocol( // Сбрасываем флаг подключения isConnecting = false + connectingSinceMs = 0L setState(ProtocolState.CONNECTED, "WebSocket onOpen callback") // Flush queue as soon as socket is open. @@ -500,6 +515,7 @@ class Protocol( override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed") isConnecting = false // Сбрасываем флаг + connectingSinceMs = 0L handleDisconnect() } @@ -511,6 +527,7 @@ class Protocol( log(" Reconnect attempts: $reconnectAttempts") t.printStackTrace() isConnecting = false // Сбрасываем флаг + connectingSinceMs = 0L _lastError.value = t.message handleDisconnect() } @@ -801,6 +818,7 @@ class Protocol( log("🔌 Manual disconnect requested") isManuallyClosed = true isConnecting = false // Сбрасываем флаг + connectingSinceMs = 0L reconnectJob?.cancel() // Отменяем запланированные переподключения reconnectJob = null handshakeJob?.cancel() @@ -823,6 +841,7 @@ class Protocol( fun reconnectNowIfNeeded(reason: String = "foreground") { val currentState = _state.value val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank() + val now = System.currentTimeMillis() log( "⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason" @@ -830,12 +849,22 @@ class Protocol( if (!hasCredentials) return - if ( + if (currentState == ProtocolState.CONNECTING && isConnecting) { + val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L + if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) { + return + } + log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect") + isConnecting = false + connectingSinceMs = 0L + runCatching { webSocket?.cancel() } + webSocket = null + setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING") + } else if ( currentState == ProtocolState.AUTHENTICATED || currentState == ProtocolState.HANDSHAKING || currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED || - currentState == ProtocolState.CONNECTED || - (currentState == ProtocolState.CONNECTING && isConnecting) + currentState == ProtocolState.CONNECTED ) { return } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 5b14678..f3861bf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -325,7 +325,9 @@ fun ChatDetailScreen( avatarRepository: AvatarRepository? = null, onImageViewerChanged: (Boolean) -> Unit = {}, isCallActive: Boolean = false, - onOpenCallOverlay: () -> Unit = {} + onOpenCallOverlay: () -> Unit = {}, + onSelectionModeChange: (Boolean) -> Unit = {}, + registerClearSelection: (() -> Unit) -> Unit = {} ) { val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val context = LocalContext.current @@ -390,6 +392,14 @@ fun ChatDetailScreen( // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() + + // Notify parent about selection mode changes so it can intercept swipe-back + LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) } + // Register selection-clear callback so parent can cancel selection on swipe-back + DisposableEffect(Unit) { + registerClearSelection { selectedMessages = emptySet() } + onDispose { registerClearSelection {} } + } // После long press AndroidView текста может прислать tap на отпускание. // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. var longPressSuppressedMessageId by remember { mutableStateOf(null) } @@ -1360,10 +1370,10 @@ fun ChatDetailScreen( // 🔥 Обработка системной кнопки назад BackHandler { - if (isInChatSearchMode) { - closeInChatSearch() - } else { - handleBackWithInputPriority() + when { + isSelectionMode -> selectedMessages = emptySet() + isInChatSearchMode -> closeInChatSearch() + else -> handleBackWithInputPriority() } } @@ -2326,11 +2336,21 @@ fun ChatDetailScreen( } // Voice mini player — shown right under the chat header when audio is playing val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() - if (!playingVoiceAttachmentId.isNullOrBlank()) { - val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() - val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() - val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() - val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() + val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() + val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() + val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() + val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() + AnimatedVisibility( + visible = !playingVoiceAttachmentId.isNullOrBlank(), + enter = expandVertically( + animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing), + expandFrom = Alignment.Top + ) + fadeIn(animationSpec = tween(220)), + exit = shrinkVertically( + animationSpec = tween(260, easing = androidx.compose.animation.core.FastOutSlowInEasing), + shrinkTowards = Alignment.Top + ) + fadeOut(animationSpec = tween(180)) + ) { val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" } val time = playingVoiceTimeLabel.trim() val voiceTitle = if (time.isBlank()) sender else "$sender at $time" 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 bf37bfe..94e1a7a 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 @@ -5882,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { return } + // ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн + // (для групп продолжаем отправлять — кто-то из участников может быть в сети) + if (!isGroupDialogKey(opponent) && !_opponentOnline.value) { + return + } + val privateKey = myPrivateKey ?: run { 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 ebcbda7..fe09658 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 @@ -66,6 +66,7 @@ import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.RecentSearchesManager +import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.CallPhase @@ -254,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String { return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized) } +private fun rosettaDev1Log(context: Context, tag: String, message: String) { + runCatching { + val dir = java.io.File(context.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) + java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n") + } +} + private val TELEGRAM_DIALOG_AVATAR_START = 10.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp @@ -314,9 +324,6 @@ fun ChatsListScreen( val view = androidx.compose.ui.platform.LocalView.current val context = androidx.compose.ui.platform.LocalContext.current - val hasNativeNavigationBar = remember(context) { - com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context) - } val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -491,22 +498,37 @@ fun ChatsListScreen( val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() - // Load dialogs when account is available + // Load dialogs as soon as public key is available. + // Private key may appear a bit later right after fresh registration on some devices. LaunchedEffect(accountPublicKey, accountPrivateKey) { - if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { - val launchStart = System.currentTimeMillis() - chatsViewModel.setAccount(accountPublicKey, accountPrivateKey) - // Устанавливаем аккаунт для RecentSearchesManager - RecentSearchesManager.setAccount(accountPublicKey) + val normalizedPublicKey = accountPublicKey.trim() + if (normalizedPublicKey.isEmpty()) return@LaunchedEffect - // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих - // сообщений - ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey) - android.util.Log.d( - "ChatsListScreen", - "✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms" - ) + val normalizedPrivateKey = accountPrivateKey.trim() + val launchStart = System.currentTimeMillis() + + chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey) + // Устанавливаем аккаунт для RecentSearchesManager + RecentSearchesManager.setAccount(normalizedPublicKey) + + // 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих + // сообщений только когда приватный ключ уже доступен. + if (normalizedPrivateKey.isNotEmpty()) { + ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey) } + + android.util.Log.d( + "ChatsListScreen", + "✅ Account init effect: pubReady=true privReady=${normalizedPrivateKey.isNotEmpty()} " + + "in ${System.currentTimeMillis() - launchStart}ms" + ) + rosettaDev1Log( + context = context, + tag = "ChatsListScreen", + message = + "Account init effect pub=${shortPublicKey(normalizedPublicKey)} " + + "privReady=${normalizedPrivateKey.isNotEmpty()}" + ) } // Status dialog state @@ -604,9 +626,44 @@ fun ChatsListScreen( LaunchedEffect(accountPublicKey) { val accountManager = AccountManager(context) val accounts = accountManager.getAllAccounts() - allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey } + val preferredPublicKey = + accountPublicKey.trim().ifBlank { + accountManager.getLastLoggedPublicKey().orEmpty() + } + allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey } } + val effectiveCurrentPublicKey = + remember(accountPublicKey, allAccounts) { + accountPublicKey.trim().ifBlank { allAccounts.firstOrNull()?.publicKey.orEmpty() } + } + val currentSidebarAccount = + remember(allAccounts, effectiveCurrentPublicKey) { + allAccounts.firstOrNull { + it.publicKey.equals(effectiveCurrentPublicKey, ignoreCase = true) + } ?: allAccounts.firstOrNull() + } + val sidebarAccountUsername = + remember(accountUsername, currentSidebarAccount) { + accountUsername.ifBlank { currentSidebarAccount?.username.orEmpty() } + } + val sidebarAccountName = + remember(accountName, sidebarAccountUsername, currentSidebarAccount, effectiveCurrentPublicKey) { + val preferredName = + when { + accountName.isNotBlank() && + !isPlaceholderAccountName(accountName) -> accountName + !currentSidebarAccount?.name.isNullOrBlank() -> + currentSidebarAccount?.name.orEmpty() + else -> accountName + } + resolveAccountDisplayName( + effectiveCurrentPublicKey, + preferredName, + sidebarAccountUsername + ) + } + // Confirmation dialogs state var dialogsToDelete by remember { mutableStateOf>(emptyList()) } var dialogToLeave by remember { mutableStateOf(null) } @@ -625,7 +682,7 @@ fun ChatsListScreen( val hapticFeedback = LocalHapticFeedback.current var showSelectionMenu by remember { mutableStateOf(false) } val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } - val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey) + val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey) .collectAsState(initial = emptySet()) // Back: drawer → закрыть, selection → сбросить @@ -656,6 +713,31 @@ fun ChatsListScreen( val topLevelRequestsCount = topLevelChatsState.requestsCount val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount + // Anti-stuck guard: + // если соединение уже AUTHENTICATED и синхронизация завершена, + // loading не должен висеть бесконечно. + LaunchedEffect(accountPublicKey, protocolState, syncInProgress, topLevelIsLoading) { + val normalizedPublicKey = accountPublicKey.trim() + if (normalizedPublicKey.isBlank()) return@LaunchedEffect + if (!topLevelIsLoading) return@LaunchedEffect + if (protocolState != ProtocolState.AUTHENTICATED || syncInProgress) return@LaunchedEffect + + delay(1200) + if ( + topLevelIsLoading && + protocolState == ProtocolState.AUTHENTICATED && + !syncInProgress + ) { + rosettaDev1Log( + context = context, + tag = "ChatsListScreen", + message = + "loading guard fired pub=${shortPublicKey(normalizedPublicKey)}" + ) + chatsViewModel.forceStopLoading("ui_guard_authenticated_no_sync") + } + } + // Dev console dialog - commented out for now /* if (showDevConsole) { @@ -806,10 +888,6 @@ fun ChatsListScreen( Modifier.fillMaxSize() .onSizeChanged { rootSize = it } .background(backgroundColor) - .then( - if (hasNativeNavigationBar) Modifier.navigationBarsPadding() - else Modifier - ) ) { ModalNavigationDrawer( drawerState = drawerState, @@ -892,16 +970,16 @@ fun ChatsListScreen( ) { AvatarImage( publicKey = - accountPublicKey, + effectiveCurrentPublicKey, avatarRepository = avatarRepository, size = 72.dp, isDarkTheme = isDarkTheme, displayName = - accountName + sidebarAccountName .ifEmpty { - accountUsername + sidebarAccountUsername } ) } @@ -953,13 +1031,13 @@ fun ChatsListScreen( ) { Column(modifier = Modifier.weight(1f)) { // Display name - if (accountName.isNotEmpty()) { + if (sidebarAccountName.isNotEmpty()) { Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { Text( - text = accountName, + text = sidebarAccountName, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White @@ -978,10 +1056,10 @@ fun ChatsListScreen( } // Username - if (accountUsername.isNotEmpty()) { + if (sidebarAccountUsername.isNotEmpty()) { Spacer(modifier = Modifier.height(2.dp)) Text( - text = "@$accountUsername", + text = "@$sidebarAccountUsername", fontSize = 13.sp, color = Color.White.copy(alpha = 0.7f) ) @@ -1022,7 +1100,9 @@ fun ChatsListScreen( Column(modifier = Modifier.fillMaxWidth()) { // All accounts list (max 5 like Telegram sidebar behavior) allAccounts.take(5).forEach { account -> - val isCurrentAccount = account.publicKey == accountPublicKey + val isCurrentAccount = + account.publicKey == + effectiveCurrentPublicKey val displayName = resolveAccountDisplayName( account.publicKey, @@ -1310,9 +1390,12 @@ fun ChatsListScreen( Column( modifier = Modifier.fillMaxWidth() - .then( - if (hasNativeNavigationBar) Modifier.navigationBarsPadding() - else Modifier + .windowInsetsPadding( + WindowInsets + .navigationBars + .only( + WindowInsetsSides.Bottom + ) ) ) { // Telegram-style update banner @@ -2842,7 +2925,17 @@ fun ChatsListScreen( onOpenCall = onOpenCallOverlay ) } - if (showVoiceMiniPlayer) { + AnimatedVisibility( + visible = showVoiceMiniPlayer, + enter = expandVertically( + animationSpec = tween(220, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top + ) + fadeIn(animationSpec = tween(220)), + exit = shrinkVertically( + animationSpec = tween(260, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top + ) + fadeOut(animationSpec = tween(180)) + ) { VoiceTopMiniPlayer( title = voiceMiniPlayerTitle, isDarkTheme = isDarkTheme, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index cf0349c..0f038e7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -14,11 +14,14 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser +import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // Job для отмены подписок при смене аккаунта private var accountSubscriptionsJob: Job? = null + private var loadingFailSafeJob: Job? = null // Список диалогов с расшифрованными сообщениями private val _dialogs = MutableStateFlow>(emptyList()) @@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio ChatsUiState() ) - // Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty) - private val _isLoading = MutableStateFlow(true) + // Загрузка + // Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван. + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() + private val loadingFailSafeTimeoutMs = 4500L private val TAG = "ChatsListVM" private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$") @@ -146,6 +152,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio return normalized.startsWith("#group:") || normalized.startsWith("group:") } + private fun rosettaDev1Log(msg: String) { + runCatching { + val app = getApplication() + val dir = java.io.File(app.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) + java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n") + } + } + private data class GroupLastSenderInfo( val senderPrefix: String, val senderKey: String @@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio /** Установить текущий аккаунт и загрузить диалоги */ fun setAccount(publicKey: String, privateKey: String) { - val setAccountStart = System.currentTimeMillis() - if (currentAccount == publicKey) { + val resolvedPrivateKey = + when { + privateKey.isNotBlank() -> privateKey + currentAccount == publicKey -> currentPrivateKey.orEmpty() + else -> "" + } + + if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) { // 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе) - if (_isLoading.value) _isLoading.value = false + if (_isLoading.value) { + _isLoading.value = false + } + loadingFailSafeJob?.cancel() return } // 🔥 Показываем skeleton пока данные грузятся _isLoading.value = true + loadingFailSafeJob?.cancel() + loadingFailSafeJob = + viewModelScope.launch { + delay(loadingFailSafeTimeoutMs) + if (_isLoading.value) { + _isLoading.value = false + android.util.Log.w( + TAG, + "Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..." + ) + rosettaDev1Log( + "Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs" + ) + } + } // 🔥 Очищаем кэш запрошенных user info при смене аккаунта requestedUserInfoKeys.clear() @@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio accountSubscriptionsJob?.cancel() currentAccount = publicKey - currentPrivateKey = privateKey + currentPrivateKey = resolvedPrivateKey // � Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences) DraftManager.setAccount(publicKey) @@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio _requestsCount.value = 0 // 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий - viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) } + if (resolvedPrivateKey.isNotEmpty()) { + viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) } + } // Запускаем все подписки в одном родительском Job для отмены при смене аккаунта accountSubscriptionsJob = viewModelScope.launch { @@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } else { mapDialogListIncremental( dialogsList = dialogsList, - privateKey = privateKey, + privateKey = resolvedPrivateKey, cache = dialogsUiCache, isRequestsFlow = false ) @@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } .filterNotNull() .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки + .catch { e -> + android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e) + rosettaDev1Log("Dialogs flow failed: ${e.message}") + if (_isLoading.value) _isLoading.value = false + emit(emptyList()) + } .collect { decryptedDialogs -> _dialogs.value = decryptedDialogs // 🚀 Убираем skeleton после первой загрузки - if (_isLoading.value) _isLoading.value = false + if (_isLoading.value) { + _isLoading.value = false + loadingFailSafeJob?.cancel() + } // 🟢 Подписываемся на онлайн-статусы всех собеседников // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный @@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio decryptedDialogs.filter { !it.isSavedMessages }.map { it.opponentKey } - subscribeToOnlineStatuses(opponentsToSubscribe, privateKey) + subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey) } } @@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } else { mapDialogListIncremental( dialogsList = requestsList, - privateKey = privateKey, + privateKey = resolvedPrivateKey, cache = requestsUiCache, isRequestsFlow = true ) @@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } } // end accountSubscriptionsJob + + accountSubscriptionsJob?.invokeOnCompletion { cause -> + if (cause != null && _isLoading.value) { + _isLoading.value = false + loadingFailSafeJob?.cancel() + android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause) + rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}") + } + } + } + + fun forceStopLoading(reason: String) { + if (_isLoading.value) { + _isLoading.value = false + loadingFailSafeJob?.cancel() + android.util.Log.w(TAG, "forceStopLoading: $reason") + rosettaDev1Log("forceStopLoading: $reason") + } } /** @@ -506,6 +575,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio */ private fun subscribeToOnlineStatuses(opponentKeys: List, privateKey: String) { if (opponentKeys.isEmpty()) return + if (privateKey.isBlank()) return // 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи! val newKeys = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 2d4fc14..78fba8c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -1169,30 +1169,46 @@ fun GroupInfoScreen( ) } - if (groupDescription.isNotBlank()) { - Spacer(modifier = Modifier.height(10.dp)) - AppleEmojiText( - text = groupDescription, - color = Color.White.copy(alpha = 0.7f), - fontSize = 12.sp, - maxLines = 2, - overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false - ) - } } } - Spacer(modifier = Modifier.height(8.dp)) + if (groupDescription.isNotBlank()) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = sectionColor + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + AppleEmojiText( + text = groupDescription, + color = primaryText, + fontSize = 16.sp, + maxLines = 8, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = true + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Description", + color = Color(0xFF8E8E93), + fontSize = 13.sp + ) + } + } + Divider( + color = borderColor, + thickness = 0.5.dp, + modifier = Modifier.fillMaxWidth() + ) + } + Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - color = sectionColor, - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth(), + color = sectionColor ) { Column { - // Add Members + // Add Members — flat Telegram style, edge-to-edge, white text Row( modifier = Modifier .fillMaxWidth() @@ -1200,27 +1216,28 @@ fun GroupInfoScreen( .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(28.dp)) Text( text = "Add Members", color = primaryText, fontSize = 16.sp, modifier = Modifier.weight(1f) ) - Icon( - imageVector = Icons.Default.PersonAdd, - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(groupMenuTrailingIconSize) - ) } Divider( color = borderColor, thickness = 0.5.dp, - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier.fillMaxWidth() ) - // Encryption Key + // Encryption Key — flat Telegram style, edge-to-edge, white text Row( modifier = Modifier .fillMaxWidth() @@ -1228,6 +1245,13 @@ fun GroupInfoScreen( .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(28.dp)) Text( text = "Encryption Key", color = primaryText, 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 3e53b9f..e1b25bb 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 @@ -14,6 +14,7 @@ import android.util.LruCache import android.webkit.MimeTypeMap import android.widget.VideoView import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.StartOffset @@ -42,6 +43,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -96,6 +99,7 @@ import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.Locale import kotlin.math.min +import kotlin.math.PI import androidx.compose.ui.platform.LocalConfiguration import androidx.core.content.FileProvider @@ -2027,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String { return "$minutes:$rem" } +private fun formatVoicePlaybackSpeedLabel(speed: Float): String { + val normalized = kotlin.math.round(speed * 10f) / 10f + return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) { + "${normalized.toInt()}x" + } else { + "${normalized}x" + } +} + +@Composable +private fun VoicePlaybackButtonBlob( + level: Float, + isOutgoing: Boolean, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val clampedLevel = level.coerceIn(0f, 1f) + val animatedLevel by animateFloatAsState( + targetValue = clampedLevel, + animationSpec = tween(durationMillis = 140), + label = "voice_blob_level" + ) + val transition = rememberInfiniteTransition(label = "voice_blob_motion") + val pulse by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "voice_blob_pulse" + ) + + val blobColor = + if (isOutgoing) { + Color.White + } else if (isDarkTheme) { + Color(0xFF5DB8FF) + } else { + PrimaryBlue + } + + Canvas(modifier = modifier) { + val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f) + val buttonRadius = 20.dp.toPx() // Play button is 40dp. + val amp = animatedLevel.coerceIn(0f, 1f) + + // Telegram-like: soft concentric glow, centered, no geometry distortion. + val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx() + val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx() + val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx() + + drawCircle( + color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)), + radius = r1, + center = center + ) + drawCircle( + color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)), + radius = r2, + center = center + ) + drawCircle( + color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)), + radius = r3, + center = center + ) + } +} + private fun formatDesktopCallDuration(durationSec: Int): String { val minutes = durationSec / 60 val seconds = durationSec % 60 @@ -2178,6 +2252,9 @@ private fun VoiceAttachment( val playbackIsPlaying by (if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false)) .collectAsState(initial = false) + val playbackSpeed by + (if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f)) + .collectAsState(initial = 1f) val isPlaying = isActiveTrack && playbackIsPlaying val (previewDurationSecRaw, previewWavesRaw) = @@ -2224,6 +2301,16 @@ private fun VoiceAttachment( } else { 0f } + val liveWaveLevel = + remember(isPlaying, progress, waves) { + if (!isPlaying || waves.isEmpty()) { + 0f + } else { + val maxIndex = waves.lastIndex.coerceAtLeast(0) + val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex) + waves[sampleIndex].coerceIn(0f, 1f) + } + } val timeText = if (isActiveTrack && playbackDurationMs > 0) { val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) @@ -2389,12 +2476,29 @@ private fun VoiceAttachment( verticalAlignment = Alignment.CenterVertically ) { Box( - modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background(actionBackground), + modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center ) { + if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) { + VoicePlaybackButtonBlob( + level = liveWaveLevel, + isOutgoing = isOutgoing, + isDarkTheme = isDarkTheme, + modifier = + Modifier.requiredSize(64.dp).graphicsLayer { + // Blob lives strictly behind the button; keep button geometry untouched. + alpha = 0.96f + compositingStrategy = CompositingStrategy.Offscreen + } + ) + } + Box( + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background(actionBackground), + contentAlignment = Alignment.Center + ) { if (downloadStatus == DownloadStatus.DOWNLOADING || downloadStatus == DownloadStatus.DECRYPTING ) { @@ -2432,6 +2536,7 @@ private fun VoiceAttachment( } } } + } } Spacer(modifier = Modifier.width(10.dp)) @@ -2484,6 +2589,38 @@ private fun VoiceAttachment( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { + if (isActiveTrack) { + val speedChipBackground = + if (isOutgoing) { + Color.White.copy(alpha = 0.2f) + } else if (isDarkTheme) { + Color(0xFF31435A) + } else { + Color(0xFFDCEBFD) + } + val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue + Box( + modifier = + Modifier.clip(RoundedCornerShape(10.dp)) + .background(speedChipBackground) + .clickable( + interactionSource = + remember { MutableInteractionSource() }, + indication = null + ) { + VoicePlaybackCoordinator.cycleSpeed() + } + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = formatVoicePlaybackSpeedLabel(playbackSpeed), + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = speedChipTextColor + ) + } + } Text( text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), fontSize = 11.sp, 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 02f634c..fa8f664 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 @@ -735,6 +735,8 @@ fun MessageBubble( message.attachments.all { it.type == AttachmentType.CALL } + val hasVoiceAttachment = + message.attachments.any { it.type == AttachmentType.VOICE } val isStandaloneGroupInvite = message.attachments.isEmpty() && @@ -874,6 +876,21 @@ fun MessageBubble( if (isCallMessage) { // Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment Modifier + } else if (hasVoiceAttachment) { + // Для voice не клипуем содержимое пузыря: + // playback-blob может выходить за границы, как в Telegram. + Modifier.background( + color = + if (isSafeSystemMessage) { + if (isDarkTheme) + Color(0xFF2A2A2D) + else Color(0xFFF0F0F4) + } else { + bubbleColor + }, + shape = bubbleShape + ) + .padding(bubblePadding) } else { Modifier.clip(bubbleShape) .then( @@ -1094,7 +1111,8 @@ fun MessageBubble( info = info, touchX = touchX, touchY = touchY, - view = textViewRef + view = textViewRef, + isOwnMessage = message.isOutgoing ) } } else null, @@ -1209,7 +1227,8 @@ fun MessageBubble( info = info, touchX = touchX, touchY = touchY, - view = textViewRef + view = textViewRef, + isOwnMessage = message.isOutgoing ) } } else null @@ -1322,7 +1341,8 @@ fun MessageBubble( info = info, touchX = touchX, touchY = touchY, - view = textViewRef + view = textViewRef, + isOwnMessage = message.isOutgoing ) } } else null, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index cbb3430..65cdf2f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -31,8 +31,10 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -62,6 +64,10 @@ class TextSelectionHelper { private set var selectedMessageId by mutableStateOf(null) private set + // True when the selected message is the user's own (blue bubble) — used to pick + // white handles against the blue background instead of the default blue handles. + var isOwnMessage by mutableStateOf(false) + private set var layoutInfo by mutableStateOf(null) private set var isActive by mutableStateOf(false) @@ -99,8 +105,10 @@ class TextSelectionHelper { info: LayoutInfo, touchX: Int, touchY: Int, - view: View? + view: View?, + isOwnMessage: Boolean = false ) { + this.isOwnMessage = isOwnMessage val layout = info.layout val localX = touchX - info.windowX val localY = touchY - info.windowY @@ -398,6 +406,10 @@ fun TextSelectionOverlay( val handleSizePx = with(density) { HandleSize.toPx() } val handleInsetPx = with(density) { HandleInset.toPx() } val highlightCornerPx = with(density) { HighlightCorner.toPx() } + // Read isOwnMessage at composition level so Canvas properly invalidates on change. + // On own (blue) bubbles use the light-blue typing color — reads better than pure white. + val handleColor = if (helper.isOwnMessage) Color(0xFF54A9EB) else HandleColor + val highlightColor = if (helper.isOwnMessage) Color(0xFF54A9EB).copy(alpha = 0.45f) else HighlightColor // Block predictive back gesture completely during text selection. // BackHandler alone doesn't prevent the swipe animation on Android 13+ @@ -515,9 +527,16 @@ fun TextSelectionOverlay( val padH = 3.dp.toPx() val padV = 2.dp.toPx() + // Build a single unified Path from all per-line rects, then fill once. + // This avoids double-alpha artifacts where adjacent lines' padding overlaps. + val highlightPath = Path() for (line in startLine..endLine) { - val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV - val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV + // Only pad the outer edges (top of first line, bottom of last line). + // Inner edges meet at lineBottom == nextLineTop so the union fills fully. + val topPad = if (line == startLine) padV else 0f + val bottomPad = if (line == endLine) padV else 0f + val lineTop = layout.getLineTop(line).toFloat() + offsetY - topPad + val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + bottomPad val left = if (line == startLine) { layout.getPrimaryHorizontal(startOffset) + offsetX - padH } else { @@ -528,13 +547,14 @@ fun TextSelectionOverlay( } else { layout.getLineRight(line) + offsetX + padH } - drawRoundRect( - color = HighlightColor, - topLeft = Offset(left, lineTop), - size = Size(right - left, lineBottom - lineTop), - cornerRadius = CornerRadius(highlightCornerPx) + highlightPath.addRoundRect( + RoundRect( + rect = Rect(left, lineTop, right, lineBottom), + cornerRadius = CornerRadius(highlightCornerPx) + ) ) } + drawPath(path = highlightPath, color = highlightColor) val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX val startHy = layout.getLineBottom(startLine).toFloat() + offsetY @@ -546,35 +566,35 @@ fun TextSelectionOverlay( helper.endHandleX = endHx helper.endHandleY = endHy - drawStartHandle(startHx, startHy, handleSizePx) - drawEndHandle(endHx, endHy, handleSizePx) + drawStartHandle(startHx, startHy, handleSizePx, handleColor) + drawEndHandle(endHx, endHy, handleSizePx, handleColor) } } } -private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) { +private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) { val half = size / 2f drawCircle( - color = HandleColor, + color = color, radius = half, center = Offset(x, y + half) ) drawRect( - color = HandleColor, + color = color, topLeft = Offset(x, y), size = Size(half, half) ) } -private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) { +private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) { val half = size / 2f drawCircle( - color = HandleColor, + color = color, radius = half, center = Offset(x, y + half) ) drawRect( - color = HandleColor, + color = color, topLeft = Offset(x - half, y), size = Size(half, half) ) 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 2acacdf..a29101b 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 @@ -8,6 +8,12 @@ import android.os.Build import android.view.inputmethod.InputMethodManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import com.airbnb.lottie.LottieProperty +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieDynamicProperties +import com.airbnb.lottie.compose.rememberLottieDynamicProperty +import com.airbnb.lottie.compose.rememberLottieComposition import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete @@ -42,6 +48,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput @@ -72,6 +79,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.repository.AvatarRepository +import com.rosetta.messenger.R import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AvatarImage @@ -225,130 +233,83 @@ private fun TelegramVoiceDeleteIndicator( 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)) - + // Ensure red dot is clearly visible first; trash appears with a delayed phase. + val trashStart = 0.28f + val reveal = ((progress - trashStart) / (1f - trashStart)).coerceIn(0f, 1f) + val lottieProgress = FastOutSlowInEasing.transform(reveal) + val lottieAlpha = FastOutSlowInEasing.transform(reveal) + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes(R.raw.chat_audio_record_delete_2) + ) 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) + val neutralColor = if (isDarkTheme) Color(0xFF8EA2B4) else Color(0xFF8FA2B3) + val panelBlendColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD) + val dynamicProperties = rememberLottieDynamicProperties( + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = dangerColor.toArgb(), + keyPath = arrayOf("Cup Red", "**") + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = dangerColor.toArgb(), + keyPath = arrayOf("Box Red", "**") + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = neutralColor.toArgb(), + keyPath = arrayOf("Cup Grey", "**") + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = neutralColor.toArgb(), + keyPath = arrayOf("Box Grey", "**") + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = panelBlendColor.toArgb(), + keyPath = arrayOf("Line 1", "**") + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = panelBlendColor.toArgb(), + keyPath = arrayOf("Line 2", "**") + ), + rememberLottieDynamicProperty( + property = LottieProperty.COLOR, + value = panelBlendColor.toArgb(), + keyPath = arrayOf("Line 3", "**") + ) + ) Box( modifier = modifier.size(28.dp), contentAlignment = Alignment.Center ) { + // Single recording dot (no duplicate red indicators). RecordBlinkDot( isDarkTheme = isDarkTheme, modifier = Modifier.graphicsLayer { - alpha = 1f - appear - scaleX = 1f - 0.14f * appear - scaleY = 1f - 0.14f * appear + alpha = 1f - lottieAlpha + scaleX = 1f - 0.12f * lottieAlpha + scaleY = 1f - 0.12f * lottieAlpha } ) - - 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 - ) + if (composition != null) { + LottieAnimation( + composition = composition, + progress = { lottieProgress }, + dynamicProperties = dynamicProperties, + modifier = Modifier + .matchParentSize() + .graphicsLayer { + alpha = lottieAlpha + scaleX = 0.92f + 0.08f * lottieAlpha + scaleY = 0.92f + 0.08f * lottieAlpha + } ) - - 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) - ) } } @@ -2348,12 +2309,13 @@ fun MessageInputBar( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 8.dp, vertical = 8.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) .zIndex(2f) .onGloballyPositioned { coordinates -> recordingInputRowHeightPx = coordinates.size.height recordingInputRowY = coordinates.positionInWindow().y - } + }, + contentAlignment = Alignment.BottomStart ) { val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED // iOS parity (VoiceRecordingOverlay.applyCurrentTransforms): @@ -2371,7 +2333,7 @@ fun MessageInputBar( targetState = isLockedOrPaused, modifier = Modifier .fillMaxWidth() - .height(48.dp) + .height(40.dp) .padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap) transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) @@ -2431,6 +2393,16 @@ fun MessageInputBar( (dragCancelProgress * 0.85f).coerceIn(0f, 1f) ) ) + val collapseToTrash = + FastOutSlowInEasing.transform( + ((leftDeleteProgress - 0.14f) / 0.86f).coerceIn(0f, 1f) + ) + val collapseShiftPx = + with(density) { (-58).dp.toPx() * collapseToTrash } + val collapseScale = 1f - 0.14f * collapseToTrash + val collapseAlpha = 1f - 0.55f * collapseToTrash + val timerToTrashShiftPx = collapseShiftPx * 0.35f + val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp Row( modifier = Modifier .fillMaxSize() @@ -2465,12 +2437,18 @@ fun MessageInputBar( fontSize = 15.sp, fontWeight = FontWeight.Bold, modifier = Modifier.graphicsLayer { - alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f) - translationX = with(density) { recordUiShift.toPx() } + alpha = + recordUiAlpha * + (1f - leftDeleteProgress * 0.22f) * + collapseAlpha + translationX = + with(density) { recordUiShift.toPx() } + timerToTrashShiftPx + scaleX = collapseScale + scaleY = collapseScale } ) - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(timerSpacerDp)) // Slide to cancel SlideToCancel( @@ -2479,8 +2457,14 @@ fun MessageInputBar( modifier = Modifier .weight(1f) .graphicsLayer { - alpha = recordUiAlpha * (1f - leftDeleteProgress) - translationX = with(density) { recordUiShift.toPx() } + alpha = + recordUiAlpha * + (1f - leftDeleteProgress) * + collapseAlpha + translationX = + with(density) { recordUiShift.toPx() } + collapseShiftPx + scaleX = collapseScale + scaleY = collapseScale } ) } @@ -2614,12 +2598,20 @@ fun MessageInputBar( } } } + val gestureCaptureOnly = isRecordingPanelComposed && keepMicGestureCapture if (!isRecordingPanelComposed || keepMicGestureCapture) { Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp) + .then( + if (gestureCaptureOnly) { + Modifier.requiredHeight(0.dp) + } else { + Modifier + .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. @@ -2908,9 +2900,11 @@ fun MessageInputBar( didCancelHaptic = true view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) } - if (rawDx <= -cancelDragThresholdPx) { + // Trigger cancel immediately while finger is still down, + // using the same threshold as release-cancel behavior. + if (rawDx <= -releaseCancelThresholdPx) { if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( - "gesture CANCEL dx=${rawDx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" + "gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode" ) cancelVoiceRecordingWithAnimation("slide-cancel") finished = true 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 15ef92d..f391d6d 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 @@ -139,6 +139,8 @@ fun SwipeBackContainer( propagateBackgroundProgress: Boolean = true, deferToChildren: Boolean = false, enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade, + // Return true to cancel the swipe — screen bounces back and onBack is NOT called. + onInterceptSwipeBack: () -> Boolean = { false }, content: @Composable () -> Unit ) { // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. @@ -523,6 +525,32 @@ fun SwipeBackContainer( ) if (shouldComplete) { + // Intercept: if owner handled back locally (e.g. clear + // message selection), bounce back without exiting. + if (onInterceptSwipeBack()) { + dismissKeyboard() + offsetAnimatable.animateTo( + targetValue = 0f, + animationSpec = + tween( + durationMillis = + ANIMATION_DURATION_EXIT, + easing = + TelegramEasing + ), + block = { + updateSharedSwipeProgress( + progress = + value / + screenWidthPx, + active = true + ) + } + ) + dragOffset = 0f + clearSharedSwipeProgressIfOwner() + return@launch + } offsetAnimatable.animateTo( targetValue = screenWidthPx, animationSpec = diff --git a/app/src/main/res/raw/chat_audio_record_delete_2.json b/app/src/main/res/raw/chat_audio_record_delete_2.json new file mode 100644 index 0000000..58d3cbd --- /dev/null +++ b/app/src/main/res/raw/chat_audio_record_delete_2.json @@ -0,0 +1 @@ +{"v":"5.6.1","fr":60,"ip":0,"op":52,"w":100,"h":100,"nm":"delete lottie grey to red","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 22 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[71.75,2914,0],"ix":2},"a":{"a":0,"k":[5.75,19,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-4.418,0],[0,0],[0,-4.418],[0,0]],"o":[[0,0],[0,-4.418],[0,0],[4.418,0],[0,0],[0,0]],"v":[[-12,6],[-12,2],[-4,-6],[4,-6],[12,2],[12,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.854901960784,0.337254901961,0.301960784314,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[18,12],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 20 Outlines 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52.25,2913,0],"ix":2},"a":{"a":0,"k":[3.25,2.8,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3,3],[67,3]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.854901960784,0.337254901961,0.301960784314,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 20 Outlines 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52.25,2913,0],"ix":2},"a":{"a":0,"k":[3.25,2.8,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3,3],[67,3]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.556862745098,0.58431372549,0.607843137255,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 22 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[71.75,2914,0],"ix":2},"a":{"a":0,"k":[5.75,19,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-4.418,0],[0,0],[0,-4.418],[0,0]],"o":[[0,0],[0,-4.418],[0,0],[4.418,0],[0,0],[0,0]],"v":[[-12,6],[-12,2],[-4,-6],[4,-6],[12,2],[12,6]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.556862745098,0.58431372549,0.607843137255,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[18,12],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":7200,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[38,58,0],"to":[0,1,0],"ti":[0,-1,0]},{"t":14,"s":[38,64,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]},{"t":14,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556999954523,0.583999992819,0.607999973671,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Line 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[50,58,0],"to":[0,1,0],"ti":[0,-1,0]},{"t":14,"s":[50,64,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]},{"t":14,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556999954523,0.583999992819,0.607999973671,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Line 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[62,58,0],"to":[0,1,0],"ti":[0,-1,0]},{"t":14,"s":[62,64,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]},{"t":14,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556999954523,0.583999992819,0.607999973671,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Box Grey","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,62,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[0,0],[0,0],[-4.95,0],[0,0],[0,4.38],[0,0]],"o":[[0,0],[0,4.38],[0,0],[4.95,0],[0,0],[0,0]],"v":[[-27,-27],[-27,19.04],[-18,27],[18,27],[27,19.04],[27,-27]],"c":false}]},{"t":14,"s":[{"i":[[0,0],[0,0],[-4.95,0],[0,0],[0,4.38],[0,0]],"o":[[0,0],[0,4.38],[0,0],[4.95,0],[0,0],[0,0]],"v":[[-27,17.5],[-27,19.04],[-18,27],[18,27],[27,19.04],[27,17.5]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.556862984452,0.584313964844,0.60784295774,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":6.6,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Path","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":54,"st":-61,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[0]},{"t":19,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[38,67.5,0],"to":[0,-1,0],"ti":[0,1,0]},{"t":23,"s":[38,61.5,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0,"y":0},"t":15,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]},{"t":23,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-47,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Line 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[0]},{"t":19,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[50,67.5,0],"to":[0,-1,0],"ti":[0,1,0]},{"t":23,"s":[50,61.5,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0,"y":0},"t":15,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]},{"t":23,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-47,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Line 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[0]},{"t":19,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":15,"s":[62,67.5,0],"to":[0,-1,0],"ti":[0,1,0]},{"t":23,"s":[62,61.5,0]}],"ix":2},"a":{"a":0,"k":[3.25,19.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0,"y":0},"t":15,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,12.5],[0,12.5],[3,15.5],[3,16],[0,19],[-3,16],[-3,15.5]],"c":true}]},{"t":23,"s":[{"i":[[-1.657,0],[0,0],[0,-1.657],[0,0],[1.657,0],[0,1.657],[0,0]],"o":[[0,0],[1.657,0],[0,0],[0,1.657],[-1.657,0],[0,0],[0,-1.657]],"v":[[0,-19],[0,-19],[3,-16],[3,16],[0,19],[-3,16],[-3,-16]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.25,19.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-47,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Cup Red","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37,"s":[5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[-5]},{"t":47,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[50.168,21,0],"to":[0,7.333,0],"ti":[0,0.833,0]},{"i":{"x":0.445,"y":1},"o":{"x":0.228,"y":0},"t":14,"s":[50.168,65,0],"to":[0,-0.833,0],"ti":[0,7.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":25,"s":[50.168,16,0],"to":[0,-7.333,0],"ti":[0,-0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[50.168,21,0],"to":[0,0.333,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":33,"s":[50.168,18,0],"to":[0,0,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":37,"s":[50.168,21,0],"to":[0,0,0],"ti":[0,0,0]},{"t":44,"s":[50.168,21,0]}],"ix":2},"a":{"a":0,"k":[83.918,2911,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1440,"h":3040,"ip":14,"op":53,"st":14,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Box Red","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50.25,72,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.3,"y":1},"o":{"x":0.3,"y":0},"t":14,"s":[{"i":[[-3.77,0],[-3.51,0],[0,6.431],[0,0],[0,0.705],[4.525,0.08],[3.46,0],[3.263,0],[0,-2.973],[0,0],[0,-1.223],[-6.47,0]],"o":[[3.78,0],[6.49,0],[0.002,-0.756],[0,0],[0,-3.045],[-3.23,-0.057],[-3.5,0],[-4.95,0],[0,0.575],[0,0],[0,6.37],[3.015,0]],"v":[[-0.016,20],[17.932,20.02],[30.271,8.762],[30.273,7.421],[30.271,5.555],[24.678,2.42],[-0.172,2.375],[-24.262,2.315],[-30.261,5.581],[-30.248,7.409],[-30.242,8.751],[-17.921,20.057]],"c":true}]},{"t":25,"s":[{"i":[[-3.77,0],[-3.51,0],[0,6.431],[0,0],[0,0.705],[4.525,0.08],[3.46,0],[3.263,0],[0,-2.972],[0,0],[0,-5.251],[-6.47,0]],"o":[[3.78,0],[6.49,0],[0,-5.137],[0,0],[0,-3.045],[-3.23,-0.057],[-3.5,0],[-4.95,0],[0,0.575],[0,0],[0,6.37],[3.015,0]],"v":[[-0.016,20],[17.932,20.02],[30.271,8.762],[30.226,-34.829],[30.223,-36.695],[24.63,-39.83],[-0.22,-39.875],[-24.31,-39.935],[-30.308,-36.669],[-30.295,-34.841],[-30.242,8.751],[-17.921,20.057]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.854901969433,0.337254911661,0.301960796118,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Oval","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":14,"op":53,"st":-329,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"Cup Grey","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37,"s":[5]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[-5]},{"t":47,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[50.168,21,0],"to":[0,7.333,0],"ti":[0,0.833,0]},{"i":{"x":0.445,"y":1},"o":{"x":0.228,"y":0},"t":14,"s":[50.168,65,0],"to":[0,-0.833,0],"ti":[0,7.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0},"t":25,"s":[50.168,16,0],"to":[0,-7.333,0],"ti":[0,-0.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":29,"s":[50.168,21,0],"to":[0,0.333,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":33,"s":[50.168,18,0],"to":[0,0,0],"ti":[0,-0.5,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":37,"s":[50.168,21,0],"to":[0,0,0],"ti":[0,0,0]},{"t":44,"s":[50.168,21,0]}],"ix":2},"a":{"a":0,"k":[83.917,2911,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1440,"h":3040,"ip":0,"op":14,"st":-61,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"Box Grey","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":100,"h":100,"ip":0,"op":14,"st":0,"bm":0}],"markers":[]} \ No newline at end of file From 0d21769399e89dbec4bc3eb8a0c994ce31ee748d Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 15 Apr 2026 21:27:56 +0500 Subject: [PATCH 32/32] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=20UI=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20(=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=93=D0=A1,=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatsListScreen.kt | 3 + .../ui/chats/calls/CallsHistoryScreen.kt | 102 ++++++++++------ .../chats/components/ChatDetailComponents.kt | 11 ++ .../ui/chats/input/ChatDetailInput.kt | 114 ++++++++++++++---- .../messenger/ui/settings/ProfileScreen.kt | 53 +++++--- app/src/main/res/raw/phone_duck.json | 1 + 6 files changed, 204 insertions(+), 80 deletions(-) create mode 100644 app/src/main/res/raw/phone_duck.json 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 fe09658..de91f4c 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 @@ -1382,6 +1382,9 @@ fun ChatsListScreen( } ) + // Keep distance from footer divider so it never overlays Settings. + Spacer(modifier = Modifier.height(8.dp)) + } // ═══════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt index 4550e95..86f6d63 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt @@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.chats.calls import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -16,7 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.CallMade @@ -34,10 +33,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalContext +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.rosetta.messenger.R import com.rosetta.messenger.database.CallHistoryRow import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.SearchUser @@ -106,16 +111,21 @@ fun CallsHistoryScreen( LazyColumn( modifier = modifier.fillMaxSize().background(backgroundColor), - contentPadding = PaddingValues(bottom = 16.dp) + contentPadding = if (items.isEmpty()) PaddingValues(0.dp) else PaddingValues(bottom = 16.dp) ) { if (items.isEmpty()) { item(key = "empty_calls") { - EmptyCallsState( - isDarkTheme = isDarkTheme, - title = "No calls yet", - subtitle = "Your call history will appear here", - modifier = Modifier.fillMaxWidth().padding(top = 64.dp) - ) + Column( + modifier = Modifier.fillParentMaxSize(), + verticalArrangement = Arrangement.Center + ) { + EmptyCallsState( + isDarkTheme = isDarkTheme, + title = "No Calls Yet", + subtitle = "Your recent voice and video calls will\nappear here.", + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp) + ) + } } } else { items(items, key = { it.messageId }) { item -> @@ -273,39 +283,63 @@ private fun EmptyCallsState( subtitle: String, modifier: Modifier = Modifier ) { - val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8) - val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23) - val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80) + val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E) + val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93) + val cardColor = if (isDarkTheme) Color(0xFF242529) else Color(0xFFF6F6FA) + val lottieComposition by rememberLottieComposition( + LottieCompositionSpec.RawRes(R.raw.phone_duck) + ) + val lottieProgress by animateLottieCompositionAsState( + composition = lottieComposition, + iterations = 1 + ) Column( - modifier = modifier.padding(horizontal = 32.dp), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Box( - modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape), - contentAlignment = Alignment.Center + Column( + modifier = Modifier + .fillMaxWidth() + .background(cardColor, RoundedCornerShape(28.dp)) + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - imageVector = Icons.Default.Call, - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(34.dp) + if (lottieComposition != null) { + LottieAnimation( + composition = lottieComposition, + progress = { lottieProgress }, + modifier = Modifier.size(184.dp) + ) + } else { + Icon( + imageVector = Icons.Default.Call, + contentDescription = null, + tint = subtitleColor, + modifier = Modifier.size(52.dp) + ) + } + Spacer(modifier = Modifier.height(18.dp)) + if (title.isNotBlank()) { + Text( + text = title, + color = titleColor, + fontSize = 22.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + } + Text( + text = subtitle, + color = subtitleColor, + fontSize = 15.sp, + lineHeight = 20.sp, + textAlign = TextAlign.Center ) } - Spacer(modifier = Modifier.height(14.dp)) - Text( - text = title, - color = titleColor, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = subtitle, - color = subtitleColor, - fontSize = 14.sp - ) } } 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 fa8f664..1598a93 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 @@ -3653,6 +3653,7 @@ fun ProfilePhotoMenu( expanded: Boolean, onDismiss: () -> Unit, isDarkTheme: Boolean, + onQrCodeClick: (() -> Unit)? = null, onSetPhotoClick: () -> Unit, onDeletePhotoClick: (() -> Unit)? = null, hasAvatar: Boolean = false @@ -3682,6 +3683,16 @@ fun ProfilePhotoMenu( dismissOnClickOutside = true ) ) { + onQrCodeClick?.let { onQrClick -> + ProfilePhotoMenuItem( + icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(TablerIcons.Scan), + text = "QR Code", + onClick = onQrClick, + tintColor = iconColor, + textColor = textColor + ) + } + ProfilePhotoMenuItem( icon = TelegramIcons.AddPhoto, text = if (hasAvatar) "Set Profile Photo" else "Add Photo", 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 a29101b..3afdfb5 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 @@ -67,6 +67,7 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex @@ -187,6 +188,7 @@ private enum class RecordUiState { @Composable private fun RecordBlinkDot( isDarkTheme: Boolean, + indicatorSize: Dp = 24.dp, modifier: Modifier = Modifier ) { var entered by remember { mutableStateOf(false) } @@ -210,7 +212,7 @@ private fun RecordBlinkDot( val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) Box( modifier = modifier - .size(28.dp) + .size(indicatorSize) .graphicsLayer { scaleX = enterScale scaleY = enterScale @@ -231,6 +233,7 @@ private fun RecordBlinkDot( private fun TelegramVoiceDeleteIndicator( cancelProgress: Float, isDarkTheme: Boolean, + indicatorSize: Dp = 24.dp, modifier: Modifier = Modifier ) { val progress = cancelProgress.coerceIn(0f, 1f) @@ -284,12 +287,13 @@ private fun TelegramVoiceDeleteIndicator( ) Box( - modifier = modifier.size(28.dp), + modifier = modifier.size(indicatorSize), contentAlignment = Alignment.Center ) { // Single recording dot (no duplicate red indicators). RecordBlinkDot( isDarkTheme = isDarkTheme, + indicatorSize = indicatorSize, modifier = Modifier.graphicsLayer { alpha = 1f - lottieAlpha scaleX = 1f - 0.12f * lottieAlpha @@ -402,16 +406,6 @@ private fun VoiceButtonBlob( ), label = "voice_btn_blob_drift_x" ) - val driftY by transition.animateFloat( - initialValue = 1f, - targetValue = -1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1270, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "voice_btn_blob_drift_y" - ) - val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF) fun createBlobPath( @@ -459,7 +453,7 @@ private fun VoiceButtonBlob( Canvas(modifier = modifier) { val center = Offset( x = size.width * 0.5f + size.width * 0.05f * driftX, - y = size.height * 0.5f + size.height * 0.04f * driftY + y = size.height * 0.5f ) val baseRadius = size.minDimension * 0.25f @@ -1119,6 +1113,9 @@ fun MessageInputBar( var isVoiceRecording by remember { mutableStateOf(false) } var isVoiceRecordTransitioning by remember { mutableStateOf(false) } var isVoiceCancelAnimating by remember { mutableStateOf(false) } + var holdCancelVisualUntilHidden by remember { mutableStateOf(false) } + var cancelProgressSeed by remember { mutableFloatStateOf(0f) } + var cancelFrozenElapsedMs by remember { mutableLongStateOf(0L) } var keepMicGestureCapture by remember { mutableStateOf(false) } var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) } var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) } @@ -1209,6 +1206,8 @@ fun MessageInputBar( isVoiceRecordTransitioning = false if (!preserveCancelAnimation) { isVoiceCancelAnimating = false + cancelFrozenElapsedMs = 0L + cancelProgressSeed = 0f } keepMicGestureCapture = false if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( @@ -1292,6 +1291,11 @@ fun MessageInputBar( fun startVoiceRecording() { if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return + // New recording session must never inherit stale cancel visuals. + isVoiceCancelAnimating = false + holdCancelVisualUntilHidden = false + cancelProgressSeed = 0f + cancelFrozenElapsedMs = 0L if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" @@ -1393,6 +1397,21 @@ fun MessageInputBar( return } keepMicGestureCapture = false + // Freeze current swipe progress so cancel animation never "jumps back" + // after slideDx is reset by stopVoiceRecording(). + val swipeSnapshot = + ((-slideDx).coerceAtLeast(0f) / with(density) { 150.dp.toPx() }) + .coerceIn(0f, 1f) + cancelProgressSeed = maxOf(cancelProgressSeed, swipeSnapshot) + cancelFrozenElapsedMs = + if (isVoicePaused && voicePausedElapsedMs > 0L) { + voicePausedElapsedMs + } else if (voiceRecordStartedAtMs > 0L) { + maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs) + } else { + voiceElapsedMs + } + holdCancelVisualUntilHidden = true 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. @@ -1592,6 +1611,9 @@ fun MessageInputBar( pendingRecordAfterPermission = false isVoiceRecordTransitioning = false isVoiceCancelAnimating = false + holdCancelVisualUntilHidden = false + cancelProgressSeed = 0f + cancelFrozenElapsedMs = 0L keepMicGestureCapture = false resetGestureState() if (isVoiceRecording || voiceRecorder != null) { @@ -2253,8 +2275,15 @@ fun MessageInputBar( targetState = isRecordingPanelVisible } // True while visible OR while enter/exit animation is still running. - val isRecordingPanelComposed = + val isRecordingPanelComposed = recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState + LaunchedEffect(isRecordingPanelComposed) { + if (!isRecordingPanelComposed) { + holdCancelVisualUntilHidden = false + cancelProgressSeed = 0f + cancelFrozenElapsedMs = 0L + } + } androidx.compose.animation.AnimatedVisibility( visibleState = recordingPanelTransitionState, // Telegram-like smooth dissolve without any vertical resize. @@ -2271,17 +2300,20 @@ fun MessageInputBar( // 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 + // Keep the scaled circle fully on-screen (no right-edge clipping). + val recordingActionOverflowX = 0.dp + val recordingActionOverflowY = 0.dp val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f } val cancelAnimProgress by animateFloatAsState( - targetValue = if (isVoiceCancelAnimating) 1f else 0f, + targetValue = if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) 1f else 0f, animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), label = "voice_cancel_anim" ) var recordUiEntered by remember { mutableStateOf(false) } - LaunchedEffect(isVoiceRecording, isVoiceCancelAnimating) { - if (isVoiceRecording || isVoiceCancelAnimating) { + val keepRecordUiVisible = + isVoiceRecording || isVoiceCancelAnimating || holdCancelVisualUntilHidden + LaunchedEffect(keepRecordUiVisible) { + if (keepRecordUiVisible) { recordUiEntered = false delay(16) recordUiEntered = true @@ -2386,9 +2418,16 @@ fun MessageInputBar( val dragCancelProgress = ((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx) .coerceIn(0f, 1f) + val seededCancelProgress = + if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) { + cancelProgressSeed + } else { + 0f + } val leftDeleteProgress = maxOf( cancelAnimProgress, + seededCancelProgress, FastOutSlowInEasing.transform( (dragCancelProgress * 0.85f).coerceIn(0f, 1f) ) @@ -2401,8 +2440,31 @@ fun MessageInputBar( with(density) { (-58).dp.toPx() * collapseToTrash } val collapseScale = 1f - 0.14f * collapseToTrash val collapseAlpha = 1f - 0.55f * collapseToTrash - val timerToTrashShiftPx = collapseShiftPx * 0.35f + // Telegram-like timer -> trash flight. + // Start a little later than trash reveal, then accelerate into bin. + val timerFlyProgress = + FastOutLinearInEasing.transform( + ((leftDeleteProgress - 0.1f) / 0.9f).coerceIn(0f, 1f) + ) + // Stop motion at bin and fade out before any overshoot. + val timerReachBinProgress = + FastOutLinearInEasing.transform( + (timerFlyProgress / 0.78f).coerceIn(0f, 1f) + ) + val timerToTrashShiftPx = + with(density) { (-46).dp.toPx() } * timerReachBinProgress + val timerToTrashScale = lerpFloat(1f, 0.52f, timerReachBinProgress) + val timerToTrashAlpha = 1f - timerReachBinProgress val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp + // Hard-hide trash right after cancel commit to prevent any one-frame reappear. + val hideIndicatorAfterCancelProgress = + if (holdCancelVisualUntilHidden && !isVoiceCancelAnimating) 1f else 0f + val timerDisplayMs = + if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) { + if (cancelFrozenElapsedMs > 0L) cancelFrozenElapsedMs else voiceElapsedMs + } else { + voiceElapsedMs + } Row( modifier = Modifier .fillMaxSize() @@ -2417,7 +2479,7 @@ fun MessageInputBar( modifier = Modifier .size(40.dp) .graphicsLayer { - alpha = recordUiAlpha + alpha = recordUiAlpha * (1f - hideIndicatorAfterCancelProgress) translationX = with(density) { recordUiShift.toPx() } }, contentAlignment = Alignment.Center @@ -2425,26 +2487,26 @@ fun MessageInputBar( TelegramVoiceDeleteIndicator( cancelProgress = leftDeleteProgress, isDarkTheme = isDarkTheme, - modifier = Modifier.size(28.dp) + indicatorSize = 24.dp ) } Spacer(modifier = Modifier.width(2.dp)) Text( - text = formatVoiceRecordTimer(voiceElapsedMs), + text = formatVoiceRecordTimer(timerDisplayMs), color = recordingTextColor, fontSize = 15.sp, fontWeight = FontWeight.Bold, modifier = Modifier.graphicsLayer { alpha = recordUiAlpha * - (1f - leftDeleteProgress * 0.22f) * + timerToTrashAlpha * collapseAlpha translationX = with(density) { recordUiShift.toPx() } + timerToTrashShiftPx - scaleX = collapseScale - scaleY = collapseScale + scaleX = collapseScale * timerToTrashScale + scaleY = collapseScale * timerToTrashScale } ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 7943570..5e3608a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -995,6 +995,7 @@ fun ProfileScreen( hasAvatar = hasAvatar, avatarRepository = avatarRepository, backgroundBlurColorId = backgroundBlurColorId, + onQrCodeClick = onNavigateToMyQr, onAvatarLongPress = { if (hasAvatar) { scope.launch { @@ -1014,13 +1015,13 @@ fun ProfileScreen( ) // ═══════════════════════════════════════════════════════════ - // 📷 CAMERA BUTTON — at boundary between header and content + // 📷 + QR FLOATING BUTTONS — at boundary between header and content // Positioned at bottom-right of header, half overlapping content area // Fades out when collapsed or when avatar is expanded // ═══════════════════════════════════════════════════════════ val cameraButtonSize = 60.dp - val cameraButtonAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f) - if (cameraButtonAlpha > 0.01f) { + val floatingButtonsAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f) + if (floatingButtonsAlpha > 0.01f) { Box( modifier = Modifier .align(Alignment.TopEnd) @@ -1028,24 +1029,31 @@ fun ProfileScreen( x = (-16).dp, y = headerHeight - cameraButtonSize / 2 ) - .size(cameraButtonSize) - .graphicsLayer { alpha = cameraButtonAlpha } - .shadow( - elevation = 4.dp, - shape = CircleShape, - clip = false - ) - .clip(CircleShape) - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)) - .clickable { showPhotoPicker = true }, - contentAlignment = Alignment.Center + .graphicsLayer { alpha = floatingButtonsAlpha } ) { - Icon( - painter = TelegramIcons.AddPhoto, - contentDescription = "Change avatar", - tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White, - modifier = Modifier.size(26.dp).offset(x = 2.dp) - ) + Box( + modifier = Modifier + .size(cameraButtonSize) + .shadow( + elevation = 4.dp, + shape = CircleShape, + clip = false + ) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2A2A2A) + else Color(0xFF0D8CF4) + ) + .clickable { showPhotoPicker = true }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.AddPhoto, + contentDescription = "Change avatar", + tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White, + modifier = Modifier.size(26.dp).offset(x = 2.dp) + ) + } } } } @@ -1103,6 +1111,7 @@ private fun CollapsingProfileHeader( hasAvatar: Boolean, avatarRepository: AvatarRepository?, backgroundBlurColorId: String = "avatar", + onQrCodeClick: () -> Unit = {}, onAvatarLongPress: () -> Unit = {} ) { @Suppress("UNUSED_VARIABLE") @@ -1379,6 +1388,10 @@ private fun CollapsingProfileHeader( expanded = showAvatarMenu, onDismiss = { onAvatarMenuChange(false) }, isDarkTheme = isDarkTheme, + onQrCodeClick = { + onAvatarMenuChange(false) + onQrCodeClick() + }, onSetPhotoClick = { onAvatarMenuChange(false) onSetPhotoClick() diff --git a/app/src/main/res/raw/phone_duck.json b/app/src/main/res/raw/phone_duck.json new file mode 100644 index 0000000..333e18d --- /dev/null +++ b/app/src/main/res/raw/phone_duck.json @@ -0,0 +1 @@ +{"tgs":1,"v":"5.5.2","fr":60,"ip":0,"op":180,"w":512,"h":512,"nm":"Old Phone","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":3,"nm":"NULL SCALE ALL","sr":1,"ks":{"o":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.4,0.4,0.4],"y":[0,0,0]},"t":76,"s":[110,110,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":91,"s":[103,103,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":148,"s":[103,103,100]},{"t":164,"s":[110,110,100]}]}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 5","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[82.549,76.375,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":63,"s":[145,145,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":69,"s":[100,100,100]},{"t":74,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":65,"op":74,"st":50,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 4","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[-81.451,17.875,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":50,"s":[145,145,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":57,"s":[100,100,100]},{"t":63,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":52,"op":63,"st":37,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 3","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[0.549,76.375,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":37,"s":[145,145,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":44,"s":[100,100,100]},{"t":50,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":39,"op":50,"st":24,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 2","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[-78.951,-36.625,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":24,"s":[143,143,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":37,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":26,"op":37,"st":10,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 1","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[0.049,17.375,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":11,"s":[143,143,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":18,"s":[100,100,100]},{"t":24,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":13,"op":24,"st":-2,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 6","parent":13,"sr":1,"ks":{"o":{"a":0,"k":30},"p":{"a":0,"k":[77.549,-36.625,0]},"a":{"a":0,"k":[-2.5,17.375,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":0,"s":[143,143,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":7,"s":[100,100,100]},{"t":13,"s":[150,150,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.125,0.145],[2.938,-0.082],[0.188,-2.375],[-0.063,-1.438],[-2.812,-0.063],[-2.438,0],[-0.063,2.75],[0.125,2.625]],"o":[[-3.125,-0.145],[-2.938,0.082],[-0.188,2.375],[0.063,1.438],[2.813,0.063],[2.438,0],[0.063,-2.75],[-0.125,-2.625]],"v":[[8.563,2.27],[-13.562,2.355],[-18.75,7.313],[-18.75,28.125],[-15.625,32.5],[9,32.5],[13.75,28.313],[13.75,9.188]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":2,"op":13,"st":-13,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"1","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[-74.047,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-78.706,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-74.047,-35.897,0]}]},"a":{"a":0,"k":[-74.047,-35.897,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.702,0],[0,0]],"o":[[0,0],[-0.03,2.474],[0,0],[0,0]],"v":[[-60.187,-49.978],[-60.503,-24.313],[-65.475,-19.806],[-95.54,-19.806]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.702,0],[0,0],[-0.212,2.474],[0,0],[-2.823,0],[0,0],[0.03,-2.638]],"o":[[-0.029,2.474],[0,0],[-2.701,0],[0,0],[0.22,-2.638],[0,0],[2.822,0],[0,0]],"v":[[-60.503,-24.313],[-65.475,-19.806],[-95.539,-19.806],[-100.079,-24.313],[-97.877,-49.977],[-92.404,-54.72],[-65.204,-54.72],[-60.187,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.139,0],[0,0],[0.26,2.091],[0,0]],"o":[[0,0],[-0.464,2.083],[0,0],[-2.146,0],[0,0],[0,0]],"v":[[-60.187,-49.978],[-64.283,-36.417],[-68.734,-32.766],[-91.022,-32.766],[-95.115,-36.439],[-97.877,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.212,2.474],[0,0],[0.03,-2.638]],"o":[[-0.029,2.474],[0,0],[0.22,-2.638],[0,0]],"v":[[-60.503,-24.313],[-100.079,-24.313],[-97.877,-49.977],[-60.187,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-80.141,-37.263]},"a":{"a":0,"k":[-80.141,-37.263]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":24,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":31,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":37,"s":[105,105]},{"t":41,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":24,"s":[{"i":[[0,0],[2.783,0],[0,0],[-2.908,0],[0,0],[0.031,-2.6]],"o":[[-0.03,2.438],[0,0],[-2.783,0],[0,0],[2.907,0],[0,0]],"v":[[-48.326,-21.513],[-53.449,-17.074],[-84.428,-17.074],[-81.199,-51.478],[-53.17,-51.478],[-48,-46.804]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":31,"s":[{"i":[[0,0],[2.748,0],[0,0],[-2.871,0],[0,0],[0.031,-2.246]],"o":[[-0.03,2.106],[0,0],[-2.748,0],[0,0],[2.87,0],[0,0]],"v":[[-58.102,-24.787],[-63.161,-20.952],[-93.752,-20.952],[-90.563,-50.673],[-62.885,-50.673],[-57.781,-46.635]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":37,"s":[{"i":[[0,0],[2.896,0],[0,0],[-3.026,0],[0,0],[0.032,-2.83]],"o":[[-0.031,2.653],[0,0],[-2.896,0],[0,0],[3.025,0],[0,0]],"v":[[-46.746,-20.384],[-52.077,-15.552],[-84.315,-15.552],[-80.954,-53],[-51.787,-53],[-46.407,-47.913]],"c":true}]},{"t":41,"s":[{"i":[[0,0],[2.783,0],[0,0],[-2.908,0],[0,0],[0.031,-2.6]],"o":[[-0.03,2.438],[0,0],[-2.783,0],[0,0],[2.907,0],[0,0]],"v":[[-48.326,-21.513],[-53.449,-17.074],[-84.428,-17.074],[-81.199,-51.478],[-53.17,-51.478],[-48,-46.804]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-74.047,-35.897]},"a":{"a":0,"k":[-74.047,-35.897]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"2","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[5.404,-35.915,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-0.803,-35.915,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[5.404,-35.915,0]}]},"a":{"a":0,"k":[5.404,-35.915,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.646,0],[0,0]],"o":[[0,0],[0.061,2.474],[0,0],[0,0]],"v":[[18.126,-49.978],[18.766,-24.313],[14.061,-19.806],[-15.393,-19.806]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.646,0],[0,0],[-0.062,2.474],[0,0],[-2.821,0],[0,0],[-0.07,-2.638]],"o":[[0.062,2.474],[0,0],[-2.647,0],[0,0],[0.062,-2.638],[0,0],[2.821,0],[0,0]],"v":[[18.765,-24.313],[14.06,-19.806],[-15.393,-19.806],[-20.108,-24.313],[-19.46,-49.977],[-14.272,-54.72],[12.929,-54.72],[18.125,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.111,0],[0,0],[0.377,2.091],[0,0]],"o":[[0,0],[-0.376,2.083],[0,0],[-2.117,0],[0,0],[0,0]],"v":[[18.126,-49.978],[14.58,-36.417],[10.33,-32.766],[-11.672,-32.766],[-15.922,-36.439],[-19.459,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.062,2.474],[0,0],[-0.07,-2.638]],"o":[[0.062,2.474],[0,0],[0.062,-2.638],[0,0]],"v":[[18.765,-24.313],[-20.108,-24.313],[-19.46,-49.977],[18.125,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-0.671,-37.263]},"a":{"a":0,"k":[-0.671,-37.263]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.733,0],[0,0],[-2.914,0],[0,0],[-0.072,-2.594]],"o":[[0.064,2.433],[0,0],[-2.734,0],[0,0],[2.914,0],[0,0]],"v":[[30.915,-21.54],[26.054,-17.11],[-4.369,-17.11],[-3.211,-51.442],[24.887,-51.442],[30.254,-46.778]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 7","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[5.404,-35.915]},"a":{"a":0,"k":[5.404,-35.915]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"3","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[83.249,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[78.557,-35.897,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[83.249,-35.897,0]}]},"a":{"a":0,"k":[83.249,-35.897,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.212,2.474],[0,0],[0,0]],"o":[[0,0],[2.702,0],[0,0],[0,0],[0,0]],"v":[[62.536,-19.807],[92.6,-19.807],[97.14,-24.313],[96.269,-34.479],[94.939,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-2.701,0],[0,0],[0.212,2.474],[0,0],[2.823,0],[0,0],[-0.03,-2.638]],"o":[[0.03,2.474],[0,0],[2.701,0],[0,0],[-0.22,-2.638],[0,0],[-2.821,0],[0,0]],"v":[[57.564,-24.313],[62.536,-19.806],[92.6,-19.806],[97.14,-24.313],[94.939,-49.977],[89.466,-54.72],[62.265,-54.72],[57.248,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.139,0],[0,0],[-0.26,2.091],[0,0]],"o":[[0,0],[0.464,2.083],[0,0],[2.145,0],[0,0],[0,0]],"v":[[57.248,-49.978],[61.345,-36.417],[65.795,-32.766],[88.084,-32.766],[92.177,-36.439],[94.939,-49.978]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0.212,2.474],[0,0],[-0.03,-2.638]],"o":[[0.03,2.474],[0,0],[-0.22,-2.638],[0,0]],"v":[[57.564,-24.313],[97.14,-24.313],[94.939,-49.977],[57.248,-49.977]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[77.201,-37.263]},"a":{"a":0,"k":[77.201,-37.263]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":0,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":7,"s":[70,70]},{"i":{"x":[0.3,0.3],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":13,"s":[105,105]},{"t":17,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 7","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":0,"s":[{"i":[[-2.777,0],[0,0],[0.218,2.438],[0,0],[2.901,0],[0,0]],"o":[[0,0],[2.777,0],[0,0],[-0.226,-2.6],[0,0],[-2.9,0]],"v":[[73.657,-17.074],[104.567,-17.074],[109.235,-21.513],[106.972,-46.804],[101.345,-51.478],[73.379,-51.478]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":7,"s":[{"i":[[-1.929,0],[0,0],[0.151,2.073],[0,0],[2.015,0],[0,0]],"o":[[0,0],[1.929,0],[0,0],[-0.157,-2.211],[0,0],[-2.014,0]],"v":[[75.139,-21.239],[96.607,-21.239],[99.849,-25.015],[98.277,-46.525],[94.369,-50.5],[74.945,-50.5]],"c":true}]},{"i":{"x":0.3,"y":1},"o":{"x":0.167,"y":0},"t":13,"s":[{"i":[[-2.923,0],[0,0],[0.229,2.547],[0,0],[3.054,0],[0,0]],"o":[[0,0],[2.923,0],[0,0],[-0.238,-2.716],[0,0],[-3.053,0]],"v":[[73.824,-16.302],[106.358,-16.302],[111.271,-20.94],[108.889,-47.367],[102.966,-52.25],[73.531,-52.25]],"c":true}]},{"t":17,"s":[{"i":[[-2.777,0],[0,0],[0.218,2.438],[0,0],[2.901,0],[0,0]],"o":[[0,0],[2.777,0],[0,0],[-0.226,-2.6],[0,0],[-2.9,0]],"v":[[73.657,-17.074],[104.567,-17.074],[109.235,-21.513],[106.972,-46.804],[101.345,-51.478],[73.379,-51.478]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[83.249,-35.897]},"a":{"a":0,"k":[83.249,-35.897]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"4","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[-76.709,17.921,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-93.319,17.921,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-76.709,17.921,0]}]},"a":{"a":0,"k":[-76.709,17.921,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.768,0],[0,0]],"o":[[0,0],[-0.031,2.636],[0,0],[0,0]],"v":[[-62.413,3.77],[-62.737,31.113],[-67.83,35.913],[-98.627,35.913]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.768,0],[0,0],[-0.217,2.636],[0,0],[-2.892,0],[0,0],[0.031,-2.81]],"o":[[-0.03,2.636],[0,0],[-2.767,0],[0,0],[0.225,-2.81],[0,0],[2.891,0],[0,0]],"v":[[-62.737,31.113],[-67.83,35.913],[-98.627,35.913],[-103.277,31.113],[-101.022,3.77],[-95.415,-1.283],[-67.553,-1.283],[-62.414,3.77]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.191,0],[0,0],[0.266,2.228],[0,0]],"o":[[0,0],[-0.475,2.219],[0,0],[-2.198,0],[0,0],[0,0]],"v":[[-62.413,3.77],[-66.609,18.217],[-71.168,22.107],[-93.999,22.107],[-98.192,18.194],[-101.021,3.77]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.217,2.636],[0,0],[0.031,-2.81]],"o":[[-0.03,2.636],[0,0],[0.225,-2.81],[0,0]],"v":[[-62.737,31.113],[-103.277,31.113],[-101.022,3.77],[-62.414,3.77]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-82.853,17.315]},"a":{"a":0,"k":[-82.853,17.315]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":50,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":57,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":63,"s":[105,105]},{"t":67,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":50,"s":[{"i":[[0,0],[2.894,0],[0,0],[-3.024,0],[0,0],[0.032,-2.749]],"o":[[-0.031,2.577],[0,0],[-2.894,0],[0,0],[3.023,0],[0,0]],"v":[[-50.464,32.431],[-55.791,37.125],[-88.004,37.125],[-84.646,0.75],[-55.501,0.75],[-50.125,5.691]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":57,"s":[{"i":[[0,0],[2.227,0],[0,0],[-2.327,0],[0,0],[0.025,-2.352]],"o":[[-0.024,2.205],[0,0],[-2.227,0],[0,0],[2.326,0],[0,0]],"v":[[-59.785,30.484],[-63.884,34.5],[-88.671,34.5],[-86.087,3.375],[-63.661,3.375],[-59.525,7.603]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":63,"s":[{"i":[[0,0],[3.06,0],[0,0],[-3.197,0],[0,0],[0.034,-2.994]],"o":[[-0.033,2.808],[0,0],[-3.06,0],[0,0],[3.196,0],[0,0]],"v":[[-48.142,33.637],[-53.775,38.75],[-87.838,38.75],[-84.287,-0.875],[-53.468,-0.875],[-47.784,4.508]],"c":true}]},{"t":67,"s":[{"i":[[0,0],[2.894,0],[0,0],[-3.024,0],[0,0],[0.032,-2.749]],"o":[[-0.031,2.577],[0,0],[-2.894,0],[0,0],[3.023,0],[0,0]],"v":[[-50.464,32.431],[-55.791,37.125],[-88.004,37.125],[-84.646,0.75],[-55.501,0.75],[-50.125,5.691]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-76.709,17.921]},"a":{"a":0,"k":[-76.709,17.921]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"5","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[5.46,17.65,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-10.699,17.65,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[5.46,17.65,0]}]},"a":{"a":0,"k":[5.46,17.65,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.622,0],[0,0]],"o":[[0,0],[0.06,2.606],[0,0],[0,0]],"v":[[18.302,4.174],[18.936,31.207],[14.274,35.953],[-14.91,35.953]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.622,0],[0,0],[-0.061,2.606],[0,0],[-2.795,0],[0,0],[-0.069,-2.779]],"o":[[0.061,2.606],[0,0],[-2.623,0],[0,0],[0.061,-2.779],[0,0],[2.795,0],[0,0]],"v":[[18.936,31.207],[14.274,35.953],[-14.91,35.953],[-19.582,31.207],[-18.94,4.174],[-13.799,-0.822],[13.153,-0.822],[18.302,4.174]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.092,0],[0,0],[0.374,2.202],[0,0]],"o":[[0,0],[-0.373,2.194],[0,0],[-2.098,0],[0,0],[0,0]],"v":[[18.302,4.174],[14.788,18.458],[10.577,22.303],[-11.224,22.303],[-15.435,18.435],[-18.939,4.174]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.061,2.606],[0,0],[-0.069,-2.779]],"o":[[0.061,2.606],[0,0],[0.061,-2.779],[0,0]],"v":[[18.936,31.207],[-19.582,31.207],[-18.94,4.174],[18.302,4.174]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-0.323,17.566]},"a":{"a":0,"k":[-0.323,17.566]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":11,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":18,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":24,"s":[105,105]},{"t":28,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":11,"s":[{"i":[[0,0],[2.729,0],[0,0],[-2.91,0],[0,0],[-0.072,-2.722]],"o":[[0.064,2.552],[0,0],[-2.73,0],[0,0],[2.91,0],[0,0]],"v":[[30.501,31.463],[25.75,36.122],[-4.691,36.122],[-3.577,1.441],[24.417,0.75],[29.839,6.334]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":18,"s":[{"i":[[0,0],[1.831,0],[0,0],[-1.952,0],[0,0],[-0.05,-2.164]],"o":[[0.045,2.029],[0,0],[-1.832,0],[0,0],[1.952,0],[0,0]],"v":[[17.996,28.795],[14.812,32.5],[-5.612,32.5],[-4.89,4.922],[13.892,4.372],[17.534,8.812]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":24,"s":[{"i":[[0,0],[2.958,0],[0,0],[-3.154,0],[0,0],[-0.078,-3.049]],"o":[[0.069,2.859],[0,0],[-2.959,0],[0,0],[3.154,0],[0,0]],"v":[[33.681,33.031],[28.531,38.25],[-4.462,38.25],[-3.255,-0.603],[27.086,-1.378],[32.964,4.878]],"c":true}]},{"t":28,"s":[{"i":[[0,0],[2.729,0],[0,0],[-2.91,0],[0,0],[-0.072,-2.722]],"o":[[0.064,2.552],[0,0],[-2.73,0],[0,0],[2.91,0],[0,0]],"v":[[30.501,31.463],[25.75,36.122],[-4.691,36.122],[-3.577,1.441],[24.417,0.75],[29.839,6.334]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[5.46,17.65]},"a":{"a":0,"k":[5.46,17.65]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"6","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[86.303,17.786,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[73.145,17.786,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[86.303,17.786,0]}]},"a":{"a":0,"k":[86.303,17.786,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.217,2.592],[0,0],[0,0]],"o":[[0,0],[2.764,0],[0,0],[0,0],[0,0]],"v":[[65.113,36.022],[95.87,36.022],[100.515,31.302],[99.624,20.653],[98.263,4.418]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-2.763,0],[0,0],[0.217,2.592],[0,0],[2.888,0],[0,0],[-0.031,-2.763]],"o":[[0.031,2.592],[0,0],[2.763,0],[0,0],[-0.225,-2.763],[0,0],[-2.886,0],[0,0]],"v":[[60.026,31.302],[65.113,36.022],[95.87,36.022],[100.515,31.302],[98.263,4.418],[92.664,-0.55],[64.836,-0.55],[59.703,4.418]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.188,0],[0,0],[-0.266,2.19],[0,0]],"o":[[0,0],[0.475,2.182],[0,0],[2.194,0],[0,0],[0,0]],"v":[[59.703,4.418],[63.894,18.623],[68.447,22.448],[91.25,22.448],[95.437,18.6],[98.263,4.418]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0.217,2.592],[0,0],[-0.031,-2.763]],"o":[[0.031,2.592],[0,0],[-0.225,-2.763],[0,0]],"v":[[60.026,31.302],[100.515,31.302],[98.263,4.418],[59.703,4.418]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[80.117,17.736]},"a":{"a":0,"k":[80.117,17.736]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-2.307,0],[0,0],[0.181,2.307],[0,0],[2.411,0],[0,0]],"o":[[0,0],[2.307,0],[0,0],[-0.188,-2.461],[0,0],[-2.41,0]],"v":[[83.13,34.622],[108.811,34.622],[112.689,30.42],[110.809,6.479],[105.945,1.25],[82.898,2.055]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[86.303,17.786]},"a":{"a":0,"k":[86.303,17.786]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"7","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[-79.619,75.656,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-106.631,75.656,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-79.619,75.656,0]}]},"a":{"a":0,"k":[-79.619,75.656,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.861,0],[0,0]],"o":[[0,0],[-0.032,2.664],[0,0],[0,0]],"v":[[-64.353,61.964],[-64.687,89.604],[-69.952,94.456],[-101.79,94.456]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.861,0],[0,0],[-0.225,2.664],[0,0],[-2.989,0],[0,0],[0.032,-2.841]],"o":[[-0.031,2.664],[0,0],[-2.86,0],[0,0],[0.233,-2.841],[0,0],[2.988,0],[0,0]],"v":[[-64.688,89.604],[-69.953,94.456],[-101.789,94.456],[-106.597,89.604],[-104.265,61.964],[-98.469,56.856],[-69.666,56.856],[-64.353,61.964]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.265,0],[0,0],[0.275,2.252],[0,0]],"o":[[0,0],[-0.491,2.243],[0,0],[-2.273,0],[0,0],[0,0]],"v":[[-64.353,61.964],[-68.69,76.568],[-73.403,80.5],[-97.005,80.5],[-101.34,76.545],[-104.265,61.964]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.225,2.664],[0,0],[0.032,-2.841]],"o":[[-0.031,2.664],[0,0],[0.233,-2.841],[0,0]],"v":[[-64.688,89.604],[-106.597,89.604],[-104.265,61.964],[-64.353,61.964]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-85.483,75.656]},"a":{"a":0,"k":[-85.483,75.656]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.931,0],[0,0],[-3.062,0],[0,0],[0.033,-2.727]],"o":[[-0.031,2.557],[0,0],[-2.931,0],[0,0],[3.061,0],[0,0]],"v":[[-52.968,88.718],[-58.364,93.375],[-90.989,93.375],[-87.588,57.287],[-58.07,57.287],[-52.625,62.19]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-79.619,75.656]},"a":{"a":0,"k":[-79.619,75.656]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"8","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[5.332,75.798,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-21.728,75.798,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[5.332,75.798,0]}]},"a":{"a":0,"k":[5.332,75.798,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.666,0],[0,0]],"o":[[0,0],[0.061,2.647],[0,0],[0,0]],"v":[[18.686,62.362],[19.331,89.822],[14.59,94.644],[-15.087,94.644]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[2.666,0],[0,0],[-0.062,2.647],[0,0],[-2.842,0],[0,0],[-0.071,-2.823]],"o":[[0.062,2.647],[0,0],[-2.667,0],[0,0],[0.062,-2.823],[0,0],[2.842,0],[0,0]],"v":[[19.331,89.822],[14.59,94.644],[-15.087,94.644],[-19.837,89.822],[-19.185,62.362],[-13.957,57.287],[13.45,57.287],[18.686,62.362]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[2.127,0],[0,0],[0.38,2.237],[0,0]],"o":[[0,0],[-0.379,2.229],[0,0],[-2.133,0],[0,0],[0,0]],"v":[[18.686,62.362],[15.113,76.872],[10.831,80.778],[-11.338,80.778],[-15.62,76.848],[-19.184,62.362]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-0.062,2.647],[0,0],[-0.071,-2.823]],"o":[[0.062,2.647],[0,0],[0.062,-2.823],[0,0]],"v":[[19.331,89.822],[-19.837,89.822],[-19.185,62.362],[18.686,62.362]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[-0.253,75.965]},"a":{"a":0,"k":[-0.253,75.965]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":37,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":44,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":50,"s":[105,105]},{"t":54,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":37,"s":[{"i":[[0,0],[2.655,0],[0,0],[-2.831,0],[0,0],[-0.07,-2.802]],"o":[[0.062,2.628],[0,0],[-2.656,0],[0,0],[2.831,0],[0,0]],"v":[[30.501,88.578],[25.879,93.375],[-3.735,93.375],[-2.652,57.664],[24.582,56.952],[29.857,62.702]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":44,"s":[{"i":[[0,0],[2.049,0],[0,0],[-2.184,0],[0,0],[-0.054,-2.283]],"o":[[0.048,2.141],[0,0],[-2.05,0],[0,0],[2.184,0],[0,0]],"v":[[22.077,86.092],[18.51,90],[-4.341,90],[-3.506,60.907],[17.51,60.327],[21.58,65.012]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":50,"s":[{"i":[[0,0],[2.92,0],[0,0],[-3.113,0],[0,0],[-0.077,-2.899]],"o":[[0.068,2.718],[0,0],[-2.921,0],[0,0],[3.113,0],[0,0]],"v":[[34.182,89.038],[29.099,94],[-3.47,94],[-2.279,57.064],[27.672,56.327],[33.474,62.274]],"c":true}]},{"t":54,"s":[{"i":[[0,0],[2.655,0],[0,0],[-2.831,0],[0,0],[-0.07,-2.802]],"o":[[0.062,2.628],[0,0],[-2.656,0],[0,0],[2.831,0],[0,0]],"v":[[30.501,88.578],[25.879,93.375],[-3.735,93.375],[-2.652,57.664],[24.582,56.952],[29.857,62.702]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[5.332,75.798]},"a":{"a":0,"k":[5.332,75.798]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"9","parent":18,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.05,"y":0},"t":76,"s":[88.977,76.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[65.191,76.003,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[88.977,76.003,0]}]},"a":{"a":0,"k":[88.977,76.003,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0.22,2.649],[0,0],[0,0]],"o":[[0,0],[2.798,0],[0,0],[0,0],[0,0]],"v":[[67.526,94.695],[98.661,94.695],[103.363,89.87],[102.461,78.985],[101.083,62.389]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843160629,0.458823531866,0.486274510622,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-2.797,0],[0,0],[0.22,2.649],[0,0],[2.924,0],[0,0],[-0.031,-2.825]],"o":[[0.031,2.649],[0,0],[2.797,0],[0,0],[-0.228,-2.825],[0,0],[-2.922,0],[0,0]],"v":[[62.377,89.871],[67.526,94.696],[98.661,94.696],[103.363,89.871],[101.084,62.389],[95.415,57.31],[67.245,57.31],[62.049,62.389]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.75686275959,0.658823549747,0.686274528503,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[-2.215,0],[0,0],[-0.269,2.239],[0,0]],"o":[[0,0],[0.481,2.23],[0,0],[2.221,0],[0,0],[0,0]],"v":[[62.049,62.389],[66.292,76.91],[70.901,80.819],[93.984,80.819],[98.223,76.886],[101.083,62.389]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0.22,2.649],[0,0],[-0.031,-2.825]],"o":[[0.031,2.649],[0,0],[-0.228,-2.825],[0,0]],"v":[[62.377,89.871],[103.363,89.871],[101.084,62.389],[62.049,62.389]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.886274516582,0.886274516582,0.886274516582,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[82.714,76.003]},"a":{"a":0,"k":[82.714,76.003]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2],"y":[1,1]},"o":{"x":[0.2,0.2],"y":[0,0]},"t":63,"s":[100,100]},{"i":{"x":[0.8,0.8],"y":[1,1]},"o":{"x":[0.8,0.8],"y":[0,0]},"t":69,"s":[70,70]},{"i":{"x":[0.7,0.7],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":74,"s":[105,105]},{"t":78,"s":[100,100]}]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.2,"y":0},"t":63,"s":[{"i":[[-2.876,0],[0,0],[0.226,2.52],[0,0],[3.005,0],[0,0]],"o":[[0,0],[2.876,0],[0,0],[-0.234,-2.687],[0,0],[-3.004,0]],"v":[[79.044,93.75],[111.054,93.75],[115.888,89.162],[113.545,63.02],[107.481,57.31],[78.755,58.189]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":69,"s":[{"i":[[-2.191,0],[0,0],[0.172,1.953],[0,0],[2.289,0],[0,0]],"o":[[0,0],[2.191,0],[0,0],[-0.178,-2.083],[0,0],[-2.288,0]],"v":[[78.259,90.25],[102.645,90.25],[106.328,86.693],[104.543,66.427],[99.923,62],[78.04,62.682]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0},"t":74,"s":[{"i":[[-3.099,0],[0,0],[0.243,2.623],[0,0],[3.238,0],[0,0]],"o":[[0,0],[3.099,0],[0,0],[-0.252,-2.798],[0,0],[-3.237,0]],"v":[[79.299,94.5],[113.797,94.5],[119.007,89.723],[116.481,62.505],[109.946,56.56],[78.988,57.475]],"c":true}]},{"t":78,"s":[{"i":[[-2.876,0],[0,0],[0.226,2.52],[0,0],[3.005,0],[0,0]],"o":[[0,0],[2.876,0],[0,0],[-0.234,-2.687],[0,0],[-3.004,0]],"v":[[79.044,93.75],[111.054,93.75],[115.888,89.162],[113.545,63.02],[107.481,57.31],[78.755,58.189]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.376470595598,0.003921568859,0.066666670144,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[88.977,76.003]},"a":{"a":0,"k":[88.977,76.003]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.05],"y":[0]},"t":76,"s":[0]},{"i":{"x":[0.8],"y":[1]},"o":{"x":[0.8],"y":[0]},"t":148,"s":[5.805]},{"t":164,"s":[0]}]},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"Panel Buttons","parent":19,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[-0.508,21.61,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[-9.685,21.61,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-0.508,21.61,0]}]},"a":{"a":0,"k":[-0.508,21.61,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[123.447,-67.648],[-125.499,-67.648]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[120.545,-67.442],[-120.406,-67.469]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[123.447,-67.648],[-125.499,-67.648]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.937254905701,0.376470595598,0.376470595598,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0.42,-3.364],[0,0]],"o":[[-3.391,0],[0,0],[0,0]],"v":[[-125.499,-67.648],[-132.169,-61.759],[-152.789,103.312]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0.42,-3.364],[0,0]],"o":[[-3.391,0],[0,0],[0,0]],"v":[[-120.406,-67.469],[-127.076,-61.58],[-174.181,103.245]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[0.42,-3.364],[0,0]],"o":[[-3.391,0],[0,0],[0,0]],"v":[[-125.499,-67.648],[-132.169,-61.759],[-152.789,103.312]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0.42,-3.364],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0.42,-3.364],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.371,-0.121],[0,0]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.559,-3.325],[0,0],[-3.391,0]],"v":[[-127.076,-61.579],[-174.181,103.245],[-167.511,110.801],[111.486,110.801],[118.152,103.204],[127.411,-61.842],[120.544,-67.442],[-120.406,-67.468]],"c":true}]},{"t":164,"s":[{"i":[[0.42,-3.364],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.600000023842,0,0.113725490868,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0],[0.42,-3.364]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0],[0,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648],[-132.169,-61.759]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.371,-0.121],[0,0],[0.42,-3.364]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.559,-3.325],[0,0],[-3.391,0],[0,0]],"v":[[-127.076,-61.579],[-174.181,103.245],[-167.511,110.801],[111.486,110.801],[118.152,103.204],[127.411,-61.842],[120.544,-67.442],[-120.406,-67.468],[-127.076,-61.579]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[0,0],[-4.043,0],[0,0],[0.528,4.026],[0,0],[3.375,0],[0,0],[0.42,-3.364]],"o":[[0,0],[-0.501,4.012],[0,0],[4.06,0],[0,0],[-0.439,-3.345],[0,0],[-3.391,0],[0,0]],"v":[[-132.169,-61.759],[-152.789,103.312],[-146.119,110.868],[145.102,110.868],[151.768,103.271],[130.112,-61.801],[123.447,-67.648],[-125.499,-67.648],[-132.169,-61.759]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.698039233685,0.015686275437,0.164705887437,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"BODY","parent":2,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":76,"s":[-1.006,194.394,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.69,"y":1},"o":{"x":0.3,"y":0},"t":91,"s":[5.446,243.876,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.705,"y":0.705},"o":{"x":0.31,"y":0.31},"t":99,"s":[5.446,236.434,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[5.446,236.434,0],"to":[0,0,0],"ti":[0,0,0]},{"t":164,"s":[-1.006,194.394,0]}]},"a":{"a":0,"k":[-1.006,194.394,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":10,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":20,"s":[100,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":30,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":39,"s":[100,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":49,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":59,"s":[100,100,100]},{"i":{"x":[0.72,0.72,0.72],"y":[1,1,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":69,"s":[101,99,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.4,0.4,0.4],"y":[0,0,0]},"t":76,"s":[106,92,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.28,0.28,0.28],"y":[0,0,0]},"t":91,"s":[98,100,100]},{"i":{"x":[0.726,0.726,0.726],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":99,"s":[100,100,100]},{"i":{"x":[0.8,0.8,0.8],"y":[1,1,1]},"o":{"x":[0.8,0.8,0.8],"y":[0,0,0]},"t":155,"s":[100,100,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":164,"s":[104,96,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":171,"s":[98,101,100]},{"t":178,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[4.431,31.947],[10.576,11.804],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[4.43,-31.948],[-5.571,-6.372],[-164.475,0],[-7.872,9.002]],"o":[[-4.431,-31.948],[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[-4.43,31.947],[7.872,9.002],[164.477,0],[5.571,-6.372]],"v":[[181.005,115.268],[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-183.018,115.268],[-180.971,181.116],[-1.007,185.559],[178.959,181.116]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[1.939,22.67],[2.811,24.113],[33.756,4.681],[33.096,0],[33.756,-4.681],[10.447,-11.805],[3.177,-22.943],[-6.911,-8.47],[-157.291,0.255],[-7.528,9.002]],"o":[[-1.622,-32.093],[-1.238,-9.591],[-33.754,-4.68],[-33.095,0],[-33.754,4.681],[-10.446,11.804],[-4.419,31.941],[6.776,8.143],[127.855,-0.157],[5.328,-6.372]],"v":[[137.373,122.908],[140.754,-78.47],[75.057,-87.823],[-4.251,-103.653],[-83.559,-87.823],[-149.255,-78.47],[-211.641,121.889],[-208.874,181.434],[-9.682,186.068],[134.717,181.116]],"c":true}]},{"t":164,"s":[{"i":[[4.431,31.947],[10.576,11.804],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[4.43,-31.948],[-5.571,-6.372],[-164.475,0],[-7.872,9.002]],"o":[[-4.431,-31.948],[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[-4.43,31.947],[7.872,9.002],[164.477,0],[5.571,-6.372]],"v":[[181.005,115.268],[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-183.018,115.268],[-180.971,181.116],[-1.007,185.559],[178.959,181.116]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.600000023842,0,0.129411771894,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-8.13,4.064],[-12.601,0],[0.723,-19.192]],"o":[[-2.944,-22.066],[8.129,-4.064],[12.601,0],[0,0]],"v":[[-184.575,170.938],[-175.746,137.352],[168.339,137.352],[183.095,167.1]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-8.031,4.064],[-12.051,0],[0.369,-17.299]],"o":[[-1.407,-17.417],[8.03,-4.064],[12.051,0],[0,0]],"v":[[-213.676,168.2],[-205.949,136.589],[125.492,137.352],[138.091,166.591]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-8.13,4.064],[-12.601,0],[0.723,-19.192]],"o":[[-2.944,-22.066],[8.129,-4.064],[12.601,0],[0,0]],"v":[[-184.575,170.938],[-175.746,137.352],[168.339,137.352],[183.095,167.1]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.96862745285,0.474509805441,0.474509805441,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[7.577,-23.603],[164.472,0],[7.871,9.005],[-13.371,7.647],[-83.589,0],[-12.607,0]],"o":[[-7.872,9.005],[-164.472,0],[-3.241,-3.707],[7.889,-4.512],[83.597,0],[16.49,4.082]],"v":[[178.964,181.11],[-1.005,185.556],[-180.974,181.11],[-175.747,137.353],[-0.486,135.279],[168.343,137.353]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[7.246,-23.603],[130.878,-0.029],[7.776,9.005],[-13.208,7.647],[-79.938,0],[-12.056,0]],"o":[[-7.528,9.005],[-157.289,0.044],[-3.202,-3.707],[7.793,-4.512],[79.946,0],[15.266,0.55]],"v":[[134.721,181.11],[-9.448,185.556],[-209.374,181.11],[-204.21,137.353],[-8.952,135.279],[124.564,137.353]],"c":true}]},{"t":164,"s":[{"i":[[7.577,-23.603],[164.472,0],[7.871,9.005],[-13.371,7.647],[-83.589,0],[-12.607,0]],"o":[[-7.872,9.005],[-164.472,0],[-3.241,-3.707],[7.889,-4.512],[83.597,0],[16.49,4.082]],"v":[[178.964,181.11],[-1.005,185.556],[-180.974,181.11],[-175.747,137.353],[-0.486,135.279],[168.343,137.353]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.917647063732,0,0.04705882445,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-12.196,10.554],[-24.311,4.111],[-24.63,0],[-31.434,-1.864],[-9.754,-4.816],[0,0]],"o":[[0,0],[8.282,-7.166],[29.677,-5.017],[38.373,0],[22.459,1.331],[11.955,5.903],[0,0]],"v":[[-185.118,134.794],[-141.546,-76.246],[-77.68,-82.806],[-3.277,-96.956],[86.592,-81.548],[138.441,-77.383],[183.198,130.093]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-12.048,10.554],[-22.908,4.11],[-23.209,0],[-29.62,-1.863],[-8.626,-6.446],[-3.826,-53.702]],"o":[[0,0],[8.181,-7.166],[27.964,-5.017],[36.158,0],[21.163,1.331],[8.582,6.413],[0.84,3.136]],"v":[[-211.976,134.539],[-143.081,-75.991],[-76.5,-82.806],[-6.391,-96.956],[78.293,-81.548],[133.622,-77.001],[136.084,128.006]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-12.196,10.554],[-24.311,4.111],[-24.63,0],[-31.434,-1.864],[-9.754,-4.816],[0,0]],"o":[[0,0],[8.282,-7.166],[29.677,-5.017],[38.373,0],[22.459,1.331],[11.955,5.903],[0,0]],"v":[[-185.118,134.794],[-141.546,-76.246],[-77.68,-82.806],[-3.277,-96.956],[86.592,-81.548],[138.441,-77.383],[183.198,130.093]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":30},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[-17.029,-27.874],[-5.558,10.589]],"o":[[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[6.234,10.205],[18.956,-36.116]],"v":[[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-180.971,181.116],[178.959,181.116]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[33.756,4.681],[33.096,0],[33.756,-4.681],[7.942,-13.356],[-24.155,-32.836],[-2.941,7.429]],"o":[[-10.446,-11.805],[-33.754,-4.68],[-33.095,0],[-33.754,4.681],[-13.991,29.97],[6.896,9.66],[9.786,-50.632]],"v":[[140.754,-78.47],[75.057,-87.823],[-4.251,-103.653],[-83.559,-87.823],[-149.255,-78.47],[-209.371,181.116],[134.717,181.116]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[35.823,4.681],[35.123,0],[35.823,-4.68],[10.575,-11.805],[-17.029,-27.874],[-5.558,10.589]],"o":[[-10.575,-11.805],[-35.821,-4.68],[-35.122,0],[-35.821,4.681],[-10.575,11.804],[6.234,10.205],[18.956,-36.116]],"v":[[145.783,-78.47],[83.158,-87.823],[-1.007,-103.653],[-85.172,-87.823],[-147.796,-78.47],[-180.971,181.116],[178.959,181.116]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.800000011921,0,0.133333340287,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[148.507,194.394],[-149.477,194.394],[-149.477,167.98],[148.507,167.98],[148.507,178.804]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[110.043,194.232],[-185.22,194.286],[-185.22,167.872],[110.742,167.862],[110.435,179.067]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[148.507,194.394],[-149.477,194.394],[-149.477,167.98],[148.507,167.98],[148.507,178.804]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.117647059262,0.113725490868,0.113725490868,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8.12},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.117647059262,0.113725490868,0.113725490868,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"Back","parent":19,"sr":1,"ks":{"p":{"a":0,"k":[132.687,57.867,0]},"a":{"a":0,"k":[132.687,57.867,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[144.653,-78.458],[181.542,175.689],[166.286,102.142],[157.125,-5.544],[154.868,-18.184]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-14.771,-36.926],[0,0],[0,0],[0,0]],"o":[[0,0],[8.404,-5.093],[0,0],[0,0],[0,0]],"v":[[140.373,-78.225],[135.172,179.668],[180.402,92.138],[172.48,-4.59],[159.672,-17.324]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[144.653,-78.458],[181.542,175.689],[166.286,102.142],[157.125,-5.544],[154.868,-18.184]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196078431,0,0.105728568283,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.669019512102,0.008920172149,0.118936725691,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":76,"op":164,"st":0,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"Top","parent":19,"sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.7,"y":1},"o":{"x":0.6,"y":0},"t":69,"s":[-1.254,-83.412,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":76,"s":[-1.254,-60.89,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.321,"y":0.65},"o":{"x":0.255,"y":0},"t":85,"s":[-2.689,-90.94,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.32,"y":1},"o":{"x":0.09,"y":0.425},"t":91,"s":[-0.042,-86.638,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":155,"s":[5.875,-86.638,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":164,"s":[-1.254,-65.638,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":171,"s":[-1.254,-87.432,0],"to":[0,0,0],"ti":[0,0,0]},{"t":178,"s":[-1.254,-83.412,0]}]},"a":{"a":0,"k":[-1.254,-83.412,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[4.117,0],[0,0],[0,-4.118],[0,0],[12.473,3.968],[0,0],[0,5.405],[0,0],[4.118,0],[0,0],[0,-4.118],[0,0]],"o":[[0,0],[0,-4.118],[0,0],[-4.117,0],[0,0],[0,5.405],[0,0],[-12.473,3.968],[0,0],[0,-4.118],[0,0],[-4.118,0],[0,0],[0,0]],"v":[[82.663,-87.823],[82.663,-136.302],[75.208,-143.758],[57.58,-143.758],[50.125,-136.302],[50.125,-107.465],[35.096,-96.893],[-37.604,-96.893],[-52.632,-107.465],[-52.632,-136.302],[-60.088,-143.758],[-77.715,-143.758],[-85.171,-136.302],[-85.171,-87.823]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0,0],[3.817,0.002],[0,0],[0.584,-3.531],[0,0],[11.607,3.722],[0,0],[-1.161,5.846],[0,0],[3.818,0.002],[0,0],[0.521,-3.599],[0,0]],"o":[[0,0],[0.009,-3.999],[0,0],[-3.817,-0.002],[0,0],[-2.13,6.169],[0,0],[-11.516,3.982],[0,0],[0.009,-3.999],[0,0],[-3.818,-0.002],[0,0],[0,0]],"v":[[76.536,-88.55],[81.195,-134.625],[74.939,-141.765],[57.958,-141.88],[51.031,-134.645],[46.662,-107.172],[32.34,-96.86],[-35.048,-96.102],[-46.757,-106.805],[-42.956,-134.451],[-49.852,-141.695],[-66.193,-141.706],[-73.121,-134.471],[-80.43,-86.527]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[4.117,0],[0,0],[0,-4.118],[0,0],[12.473,3.968],[0,0],[0,5.405],[0,0],[4.118,0],[0,0],[0,-4.118],[0,0]],"o":[[0,0],[0,-4.118],[0,0],[-4.117,0],[0,0],[0,5.405],[0,0],[-12.473,3.968],[0,0],[0,-4.118],[0,0],[-4.118,0],[0,0],[0,0]],"v":[[82.663,-87.823],[82.663,-136.302],[75.208,-143.758],[57.58,-143.758],[50.125,-136.302],[50.125,-107.465],[35.096,-96.893],[-37.604,-96.893],[-52.632,-107.465],[-52.632,-136.302],[-60.088,-143.758],[-77.715,-143.758],[-85.171,-136.302],[-85.171,-87.823]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.588235318661,0,0.113725490868,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-2.233,1.527],[-2.585,-0.94],[0,0]],"o":[[0,0],[2.233,-1.528],[2.585,0.94],[0,0]],"v":[[50.125,-122.633],[55.281,-137.99],[77.312,-137.99],[82.663,-117.362]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-2.073,1.482],[-2.394,-0.914],[0,0]],"o":[[0,0],[2.073,-1.482],[2.394,0.914],[0,0]],"v":[[50.059,-123.512],[55.814,-136.281],[76.238,-136.268],[78.788,-119.301]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-2.233,1.527],[-2.585,-0.94],[0,0]],"o":[[0,0],[2.233,-1.528],[2.585,0.94],[0,0]],"v":[[50.125,-122.633],[55.281,-137.99],[77.312,-137.99],[82.663,-117.362]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.952941179276,0.54509806633,0.54509806633,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-3.617,2.014],[-5.039,-0.025],[-1.565,-1.589],[0,0]],"o":[[0,0],[1.616,-0.899],[5.414,0.025],[2.248,2.285],[0,0]],"v":[[-85.171,-123.532],[-80.512,-138.588],[-69.287,-139.431],[-56.413,-137.71],[-52.632,-121.851]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[-3.358,1.953],[-4.671,-0.026],[-1.447,-1.544],[0,0]],"o":[[0,0],[1.5,-0.872],[5.019,0.027],[2.079,2.22],[0,0]],"v":[[-74.202,-123.487],[-68.797,-136.687],[-58.389,-137.499],[-46.458,-135.821],[-44.931,-121.814]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-3.617,2.014],[-5.039,-0.025],[-1.565,-1.589],[0,0]],"o":[[0,0],[1.616,-0.899],[5.414,0.025],[2.248,2.285],[0,0]],"v":[[-85.171,-123.532],[-80.512,-138.588],[-69.287,-139.431],[-56.413,-137.71],[-52.632,-121.851]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.952941179276,0.54509806633,0.54509806633,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[1.608,9.505],[0.933,20.36],[13.17,-1.74],[0,0],[0,0]],"o":[[-13.17,-1.74],[3.909,23.115],[-9.28,2.61],[0,0],[0,0],[0,0]],"v":[[82.661,-99.566],[50.091,-106.526],[-52.6,-106.526],[-85.17,-99.567],[-85.17,-87.827],[82.661,-87.826]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0.623,9.785],[1.098,19.756],[8.819,2.905],[0,0],[0,0]],"o":[[-11.998,1.412],[3.975,21.514],[-7.271,3.881],[0,0],[0,0],[0,0]],"v":[[77.584,-101],[47.495,-107.842],[-47.52,-107.557],[-77.176,-102.753],[-79.034,-86.804],[76.534,-88.553]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[1.608,9.505],[0.933,20.36],[13.17,-1.74],[0,0],[0,0]],"o":[[-13.17,-1.74],[3.909,23.115],[-9.28,2.61],[0,0],[0,0],[0,0]],"v":[[82.661,-99.566],[50.091,-106.526],[-52.6,-106.526],[-85.17,-99.567],[-85.17,-87.827],[82.661,-87.826]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.800000011921,0,0.133333340287,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0.5,-8.669],[0,0],[0,21.465],[0,0],[0.796,-10.941],[0,0],[0,0]],"o":[[-0.663,-10.073],[0,0],[0,22.215],[0,0],[-1.243,-12.16],[0,0],[0,0],[0,0]],"v":[[82.663,-136.302],[50.125,-136.302],[50.125,-107.465],[-52.632,-107.465],[-52.632,-136.302],[-85.171,-136.302],[-85.171,-87.823],[82.663,-87.823]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[0.483,-8.417],[0,0],[0.246,20.839],[0,0],[0.762,-10.623],[0,0],[0,0]],"o":[[-0.593,-9.781],[0,0],[0.255,21.567],[0,0],[-1.126,-11.808],[0,0],[0,0],[0,0]],"v":[[81.195,-134.625],[51.031,-134.645],[46.662,-107.172],[-47.31,-105.951],[-42.956,-134.451],[-73.121,-134.471],[-79.036,-86.801],[76.536,-88.55]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0.5,-8.669],[0,0],[0,21.465],[0,0],[0.796,-10.941],[0,0],[0,0]],"o":[[-0.663,-10.073],[0,0],[0,22.215],[0,0],[-1.243,-12.16],[0,0],[0,0],[0,0]],"v":[[82.663,-136.302],[50.125,-136.302],[50.125,-107.465],[-52.632,-107.465],[-52.632,-136.302],[-85.171,-136.302],[-85.171,-87.823],[82.663,-87.823]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.901960790157,0.031372550875,0.043137256056,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[63.112,0],[0,12.921],[-43.246,0],[0,0],[33.857,25.923]],"o":[[0,0],[-33.869,0],[0,0],[0,0],[18.504,0],[0,0]],"v":[[50.125,-134.869],[-1.006,-117.362],[-52.632,-134.869],[-52.632,-83.412],[52.753,-83.412],[50.125,-134.869]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[0,0],[44.838,0.635],[1.854,9.43],[-40.086,0.451],[0,0],[31.331,25.191]],"o":[[0,0],[-31.383,-0.676],[0,0],[0,0],[17.152,-0.193],[0,0]],"v":[[50.264,-131.979],[2.316,-115.52],[-42.959,-133.059],[-48.824,-82.857],[48.862,-83.956],[51.028,-133.253]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[63.112,0],[0,12.921],[-43.246,0],[0,0],[33.857,25.923]],"o":[[0,0],[-33.869,0],[0,0],[0,0],[18.504,0],[0,0]],"v":[[50.125,-134.869],[-1.006,-117.362],[-52.632,-134.869],[-52.632,-83.412],[52.753,-83.412],[50.125,-134.869]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.517647087574,0.011764706112,0.168627455831,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.698039233685,0.015686275437,0.164705887437,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[79.75,-139.75],[78.632,-138.771],[68.5,-85],[82,-81.75]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.8,"y":0},"t":148,"s":[{"i":[[1.286,2.856],[4.922,0.427],[0,0],[0,0]],"o":[[-0.729,-1.619],[-2.393,-0.208],[0,0],[0,0]],"v":[[85.968,-135.541],[77.896,-141.58],[68.5,-85],[89.739,-81.75]],"c":true}]},{"t":164,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[79.75,-139.75],[78.632,-138.771],[68.5,-85],[82,-81.75]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196078431,0,0.105728568283,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.670588235294,0.007843137255,0.117647058824,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Shape 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":22,"ty":4,"nm":"Phone","parent":2,"sr":1,"ks":{"r":{"a":1,"k":[{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":66,"s":[0]},{"i":{"x":[0.677],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":75,"s":[2]},{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":81,"s":[-4]},{"i":{"x":[0.65],"y":[1.5]},"o":{"x":[0.3],"y":[0]},"t":93,"s":[1]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.35],"y":[0.286]},"t":100,"s":[2]},{"i":{"x":[0.65],"y":[1.107]},"o":{"x":[0.312],"y":[0]},"t":112,"s":[-1]},{"i":{"x":[0.664],"y":[0.828]},"o":{"x":[0.324],"y":[0.054]},"t":120,"s":[-2]},{"i":{"x":[0.684],"y":[1]},"o":{"x":[0.344],"y":[0.366]},"t":133,"s":[1]},{"i":{"x":[0.678],"y":[1]},"o":{"x":[0.344],"y":[0]},"t":142,"s":[1]},{"i":{"x":[0.755],"y":[1.055]},"o":{"x":[0.434],"y":[0]},"t":150,"s":[-1]},{"i":{"x":[0.597],"y":[1]},"o":{"x":[0.606],"y":[0.114]},"t":156,"s":[-4]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":166,"s":[2]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":173,"s":[-1]},{"t":179,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.28,"y":0},"t":0,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.3,"y":0},"t":10,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.28,"y":0},"t":20,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.3,"y":0},"t":30,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.28,"y":0},"t":39,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.3,"y":0},"t":49,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.28,"y":0},"t":59,"s":[-0.646,-171.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":1},"o":{"x":0.5,"y":0},"t":69,"s":[-0.646,-168.88,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.4,"y":0},"t":76,"s":[-0.646,-122.153,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.3,"y":1},"o":{"x":0.28,"y":0},"t":91,"s":[14.408,-219.941,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.579,"y":0.956},"o":{"x":0.3,"y":0},"t":110,"s":[14.408,-191.708,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.726,"y":1},"o":{"x":0.354,"y":0.06},"t":129,"s":[14.408,-218.355,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.9,"y":0},"t":148,"s":[14.408,-202.043,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":164,"s":[-0.646,-139.244,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":171,"s":[-0.646,-179.88,0],"to":[0,0,0],"ti":[0,0,0]},{"t":178,"s":[-0.646,-171.88,0]}]},"a":{"a":0,"k":[-0.646,-171.88,0]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-100.514,4.581],[-20.462,-23.581],[11.96,-0.826],[10.39,13.069],[15.761,10.932],[48.792,-1.867],[22.887,-6.352],[7.682,-13.671],[11.074,0.87],[-21.052,21.228]],"o":[[9.833,-9.914],[94.558,-4.31],[3.468,3.998],[-12.503,0.864],[-4.407,-5.543],[-23.319,-16.176],[-33.08,1.265],[-6.851,1.901],[-12.411,22.085],[-8.112,-0.637],[0,0]],"v":[[-167.411,-137.44],[-14.224,-175.451],[172.789,-124.648],[175.058,-92.1],[114.091,-112.79],[99.391,-147.283],[-13.154,-167.691],[-84.798,-156.321],[-117.334,-129.238],[-177.715,-96.539],[-167.411,-137.44]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[-94.612,1.966],[-20.861,-23.851],[19.122,-6.061],[9.083,19.2],[15.463,11.112],[44.202,-0.328],[20.402,-5.835],[9.118,-10.735],[9.861,0.822],[-18.806,19.593]],"o":[[8.784,-9.151],[86.705,-1.801],[3.536,4.044],[-13.058,3.378],[-3.259,-6.239],[-20.232,-14.474],[-30.819,0.228],[-6.107,1.747],[-13.386,16.33],[-7.223,-0.602],[0,0]],"v":[[-143.922,-145.31],[-2.396,-179.199],[167.287,-132.627],[165.62,-94.438],[114.709,-118.878],[93.582,-150.766],[-1.422,-171.222],[-68.643,-158.885],[-101.398,-135.756],[-148.772,-109.573],[-144.555,-145.361]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-100.514,4.581],[-20.462,-23.581],[11.96,-0.826],[10.39,13.069],[15.761,10.932],[48.792,-1.867],[22.887,-6.352],[7.682,-13.671],[11.074,0.87],[-21.052,21.228]],"o":[[9.833,-9.914],[94.558,-4.31],[3.468,3.998],[-12.503,0.864],[-4.407,-5.543],[-23.319,-16.176],[-33.08,1.265],[-6.851,1.901],[-12.411,22.085],[-8.112,-0.637],[0,0]],"v":[[-167.411,-137.44],[-14.224,-175.451],[172.789,-124.648],[175.058,-92.1],[114.091,-112.79],[99.391,-147.283],[-13.154,-167.691],[-84.798,-156.321],[-117.334,-129.238],[-177.715,-96.539],[-167.411,-137.44]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"gf","o":{"a":0,"k":100},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.56,1,1,1,1,1,1,1,0,1,0.56,0.65,1,0.3]}},"s":{"a":0,"k":[-4,-176]},"e":{"a":0,"k":[-2.005,-111.148]},"t":1,"nm":"Gradient Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":70},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[7.125,11.053],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[22.234,-7.095]],"o":[[-21.635,-5.197],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-3.502,8.603],[0,0]],"v":[[144.075,-65.926],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-139.145,-68.471]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[7.908,21.99],[4.953,7.034],[77.249,-23.351],[3.872,-9.988],[19.822,-6.651]],"o":[[-22.123,-5.289],[-6.488,-17.33],[-10.418,-10.778],[25.017,-10.47],[-3.142,8.104],[0,0]],"v":[[130.134,-67.359],[79.328,-105.991],[69.035,-139.37],[-91.618,-138.577],[-75.639,-98.536],[-115.515,-76.475]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[7.125,11.053],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[22.234,-7.095]],"o":[[-21.635,-5.197],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-3.502,8.603],[0,0]],"v":[[144.075,-65.926],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-139.145,-68.471]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[21.571,6.62],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.764,-1.958],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603]],"o":[[-9.818,-3.013],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-14.112,7.337],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945]],"c":true}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[19.195,6.277],[-4.445,29.314],[-52.262,14.542],[-54.295,-11.512],[-4.808,-36.22],[7.323,-2.426],[13.594,20.785],[8.492,8.786],[60.007,-20.064],[3.872,-9.988]],"o":[[-8.737,-2.857],[2.368,-15.62],[62.796,-17.473],[50.208,10.633],[4.099,32.038],[-24.487,8.301],[-10.097,-15.368],[-10.418,-10.778],[11.623,-1.595],[-5.578,14.389]],"v":[[-160.516,-71.015],[-171.505,-131.789],[-100.074,-183.506],[92.361,-189.253],[191.609,-120.043],[178.316,-64.692],[82.69,-94.466],[68.272,-140.09],[-82.787,-139.781],[-75.639,-98.536]],"c":true}]},{"t":164,"s":[{"i":[[21.571,6.62],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.764,-1.958],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603]],"o":[[-9.818,-3.013],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-14.112,7.337],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945]],"c":true}]}]},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.600000023842,0,0.129411771894,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[4.893,20.992],[36.772,-0.09],[0.399,-14.989],[-9.858,-5.757],[0,0]],"o":[[-11.92,-1.602],[-5.807,-24.911],[0,0],[-0.4,14.989],[9.856,5.758],[0,0]],"v":[[160.369,-61.617],[105.003,-102.205],[62.619,-147.194],[89.711,-123.001],[95.165,-86.551],[160.369,-61.617]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[4.941,21.186],[28.374,3.218],[0.456,-15.455],[-10.069,-5.829],[0,0]],"o":[[-12.193,-1.641],[-5.863,-25.142],[0,0],[-0.457,15.455],[10.067,5.829],[0,0]],"v":[[147.871,-63.515],[90.455,-106.709],[53.216,-151.112],[75.642,-127.722],[86.298,-86.243],[145.029,-63.085]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[4.893,20.992],[36.772,-0.09],[0.399,-14.989],[-9.858,-5.757],[0,0]],"o":[[-11.92,-1.602],[-5.807,-24.911],[0,0],[-0.4,14.989],[9.856,5.758],[0,0]],"v":[[160.369,-61.617],[105.003,-102.205],[62.619,-147.194],[89.711,-123.001],[95.165,-86.551],[160.369,-61.617]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":45},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-3.883,17.793],[-41.501,-3.401],[-0.399,-14.989],[9.857,-5.757],[0,0]],"o":[[11.921,-1.602],[5.05,-23.136],[0,0],[0.401,14.989],[-9.857,5.758],[0,0]],"v":[[-163.082,-61.617],[-107.716,-102.205],[-65.332,-144.817],[-92.424,-123.001],[-97.877,-86.551],[-163.082,-61.617]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[-3.34,17.041],[-52.087,4.896],[-0.318,-14.131],[8.795,-5.411],[0,0]],"o":[[-1.885,-4.113],[3.87,-22.05],[0,0],[0.318,14.131],[-8.795,5.411],[0,0]],"v":[[-105.703,-81.409],[-107.326,-110.582],[-49.123,-149.122],[-75.524,-128.984],[-80.477,-90.632],[-138.619,-71.237]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-3.883,17.793],[-41.501,-3.401],[-0.399,-14.989],[9.857,-5.757],[0,0]],"o":[[11.921,-1.602],[5.05,-23.136],[0,0],[0.401,14.989],[-9.857,5.758],[0,0]],"v":[[-163.082,-61.617],[-107.716,-102.205],[-65.332,-144.817],[-92.424,-123.001],[-97.877,-86.551],[-163.082,-61.617]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.419607847929,0,0.090196080506,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":45},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 6","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[8.322,8.785],[68.059,-23.159],[4.324,-10.96],[5.262,-1.564],[-36.545,23.812],[-50.821,3.964],[-26.19,-9.101],[-12.873,-10.885],[40.508,13.836],[13.288,21.296]],"o":[[-6.504,-10.43],[-10.212,-10.78],[-12.536,4.274],[-6.214,15.782],[-49.508,14.711],[34.714,-22.619],[65.535,-5.113],[30.426,10.574],[29.07,24.58],[0,0],[0,0]],"v":[[92.143,-93.182],[83.197,-138.469],[-84.814,-139.707],[-92.475,-97.087],[-162.289,-65.301],[-162.144,-141.59],[-27.293,-174.724],[110.229,-159.483],[167.365,-129.639],[159.912,-63.813],[92.143,-93.182]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[8.488,9.077],[60.667,-21.755],[3.873,-10.327],[4.691,-1.465],[-34.965,21.265],[-46.411,2.668],[-26.836,-8.718],[-12.354,-12.093],[56.415,11.67],[13.53,21.511]],"o":[[-6.623,-10.535],[-10.416,-11.139],[-11.177,4.008],[-5.576,14.867],[-44.134,13.785],[32.088,-19.515],[55.106,-3.281],[31.335,10.177],[26.254,25.469],[0,0],[0,0]],"v":[[83.227,-92.939],[69.857,-140.376],[-65.135,-144.338],[-75.637,-100.556],[-137.902,-74.709],[-139.473,-148.79],[-11.792,-179.695],[105.977,-165.808],[166.954,-136.101],[145.447,-64.97],[83.227,-92.939]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[8.322,8.785],[68.059,-23.159],[4.324,-10.96],[5.262,-1.564],[-36.545,23.812],[-50.821,3.964],[-26.19,-9.101],[-12.873,-10.885],[40.508,13.836],[13.288,21.296]],"o":[[-6.504,-10.43],[-10.212,-10.78],[-12.536,4.274],[-6.214,15.782],[-49.508,14.711],[34.714,-22.619],[65.535,-5.113],[30.426,10.574],[29.07,24.58],[0,0],[0,0]],"v":[[92.143,-93.182],[83.197,-138.469],[-84.814,-139.707],[-92.475,-97.087],[-162.289,-65.301],[-162.144,-141.59],[-27.293,-174.724],[110.229,-159.483],[167.365,-129.639],[159.912,-63.813],[92.143,-93.182]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.901960790157,0.031372550875,0.043137256056,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 7","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.04,"y":0},"t":76,"s":[{"i":[[0,0],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.864,-1.753],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[25.798,3.798]],"o":[[-10.16,-1.496],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-10.88,4.935],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275],[0,0]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-185.675,-61.341]],"c":false}]},{"i":{"x":0.8,"y":1},"o":{"x":0.78,"y":0},"t":148,"s":[{"i":[[0,0],[-2.95,28.689],[-52.262,14.542],[-54.25,-13.547],[-7.557,-38.134],[4.056,-1.523],[14.505,20.182],[8.492,8.786],[60.675,-21.018],[3.872,-9.988],[22.968,3.624]],"o":[[-9.045,-1.427],[1.618,-15.732],[62.796,-17.473],[52.826,13.17],[6.326,33.707],[-19.451,6.754],[-11.333,-15.217],[-10.418,-10.778],[-11.181,3.873],[-5.578,14.389],[0,0]],"v":[[-158.742,-71.015],[-173.786,-129.242],[-100.074,-183.506],[92.361,-189.253],[191.381,-120.422],[178.214,-64.069],[83.224,-90.902],[69.146,-139.867],[-68.71,-141.407],[-75.639,-98.536],[-158.742,-71.015]],"c":false}]},{"t":164,"s":[{"i":[[0,0],[-3.223,30.439],[-58.631,15.531],[-52.908,-13.636],[-2.734,-32.192],[3.864,-1.753],[13.288,20.613],[8.325,8.503],[68.056,-22.418],[4.316,-10.603],[25.798,3.798]],"o":[[-10.16,-1.496],[1.767,-16.692],[70.449,-18.661],[48.619,12.532],[2.735,32.191],[-10.88,4.935],[-6.506,-10.093],[-10.213,-10.43],[-12.541,4.131],[-6.218,15.275],[0,0]],"v":[[-185.675,-61.341],[-202.748,-123.078],[-120.159,-180.789],[100.759,-183.77],[197.486,-117.598],[185.398,-62.058],[92.147,-91.163],[83.198,-135.001],[-84.813,-136.193],[-92.471,-94.945],[-185.675,-61.341]],"c":false}]}]},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.78823530674,0,0.188235297799,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 8","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":23,"ty":4,"nm":"Waves","sr":1,"ks":{"p":{"a":1,"k":[{"i":{"x":0.2,"y":1},"o":{"x":0.05,"y":0},"t":78,"s":[269.919,77.725,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":116,"s":[269.919,92.725,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.2,"y":1},"o":{"x":0.05,"y":0},"t":117,"s":[269.919,77.725,0],"to":[0,0,0],"ti":[0,0,0]},{"t":155,"s":[269.919,92.725,0]}]},"a":{"a":0,"k":[197.919,-247.275,0]},"s":{"a":1,"k":[{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.05,0.05,0.05],"y":[0,0,0]},"t":78,"s":[90,60,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":116,"s":[90,100,100]},{"i":{"x":[0.2,0.2,0.2],"y":[1,1,1]},"o":{"x":[0.05,0.05,0.05],"y":[0,0,0]},"t":117,"s":[90,60,100]},{"t":155,"s":[90,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-12.101,9.158]],"o":[[11.76,9.158],[0,0]],"v":[[176.273,-245.408],[219.565,-245.408]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843137255,0.458823559331,0.486274539723,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":80,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":90,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":101,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":109,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":119,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":129,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":140,"s":[100]},{"t":148,"s":[0]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-22.322,16.893]],"o":[[21.693,16.893],[0,0]],"v":[[157.991,-226.643],[237.847,-226.643]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843137255,0.458823559331,0.486274539723,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":85,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":95,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":106,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":113,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":124,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":134,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":145,"s":[100]},{"t":152,"s":[0]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[-31.634,23.94]],"o":[[30.743,23.94],[0,0]],"v":[[141.334,-206.801],[254.504,-206.801]],"c":false}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.607843137255,0.458823559331,0.486274539723,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":8},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":90,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":99,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":110,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":116,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":129,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":138,"s":[100]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":149,"s":[100]},{"t":155,"s":[0]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[197.919,-217.127]},"a":{"a":0,"k":[197.919,-217.127]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":180,"st":0,"bm":0}]} \ No newline at end of file