diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d21b6bd..edd7d33 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,7 +54,12 @@ android { kotlinOptions { jvmTarget = "11" } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + packaging { + resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } + jniLibs { + useLegacyPackaging = true + } + } } dependencies { 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 f01e6c9..dfb33de 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 @@ -290,6 +290,13 @@ fun MessageBubble( else if (isDarkTheme) Color.White else Color(0xFF000000) } + // 🔗 Цвет ссылок: для исходящих (синий фон) - светлый, для входящих - стандартный синий + val linkColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне + else Color(0xFF2196F3) // Стандартный Material Blue для входящих + } + val timeColor = remember(message.isOutgoing, isDarkTheme) { if (message.isOutgoing) Color.White.copy(alpha = 0.7f) @@ -606,7 +613,8 @@ fun MessageBubble( AppleEmojiText( text = message.text, color = textColor, - fontSize = 16.sp + fontSize = 16.sp, + linkColor = linkColor ) }, timeContent = { @@ -652,7 +660,8 @@ fun MessageBubble( AppleEmojiText( text = message.text, color = textColor, - fontSize = 17.sp + fontSize = 17.sp, + linkColor = linkColor ) }, timeContent = { @@ -689,7 +698,8 @@ fun MessageBubble( AppleEmojiText( text = message.text, color = textColor, - fontSize = 17.sp + fontSize = 17.sp, + linkColor = linkColor ) }, timeContent = { 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 3778cb6..5af3d67 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 @@ -1,16 +1,24 @@ package com.rosetta.messenger.ui.components import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable +import android.net.Uri import android.text.Editable import android.text.SpannableStringBuilder +import android.text.TextPaint import android.text.TextWatcher +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan import android.text.style.ImageSpan import android.util.AttributeSet import android.util.LruCache +import android.util.Patterns import android.view.Gravity +import android.view.View import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.FrameLayout @@ -305,6 +313,7 @@ fun AppleEmojiTextField( /** * TextView с Apple эмодзи (для отображения, не редактирования) + * 🔥 Поддержка кликабельных ссылок (URL, email, телефоны) */ @Composable fun AppleEmojiText( @@ -314,7 +323,9 @@ fun AppleEmojiText( fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified, fontWeight: androidx.compose.ui.text.font.FontWeight? = null, maxLines: Int = Int.MAX_VALUE, - overflow: android.text.TextUtils.TruncateAt? = null + overflow: android.text.TextUtils.TruncateAt? = null, + linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue + enableLinks: Boolean = true // 🔥 Включить кликабельные ссылки ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value @@ -344,6 +355,11 @@ fun AppleEmojiText( if (overflow != null) { ellipsize = overflow } + // 🔥 Включаем кликабельные ссылки + if (enableLinks) { + setLinkColor(linkColor.toArgb()) + enableClickableLinks(true) + } } }, update = { view -> @@ -355,6 +371,11 @@ fun AppleEmojiText( if (overflow != null) { view.ellipsize = overflow } + // 🔥 Обновляем настройки ссылок + if (enableLinks) { + view.setLinkColor(linkColor.toArgb()) + view.enableClickableLinks(true) + } }, modifier = modifier ) @@ -365,6 +386,7 @@ fun AppleEmojiText( * 🔥 Поддерживает: * - Реальные Unicode эмодзи (😀) * - Текстовый формат :emoji_1f600: (как в React Native версии) + * - Кликабельные ссылки (URL, email, телефоны) */ class AppleEmojiTextView @JvmOverloads constructor( context: Context, @@ -377,13 +399,48 @@ class AppleEmojiTextView @JvmOverloads constructor( // 🔥 Паттерн для :emoji_XXXX: формата (из React Native) private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):") private val bitmapCache = LruCache(100) + + // 🔥 Паттерны для ссылок + private val URL_PATTERN = Pattern.compile( + "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)" + + "|(www\\.[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)" + + "|([\\w\\-]+\\.(com|org|net|ru|io|dev|app|me|co|info|biz|edu|gov|uk|de|fr|jp|cn|br|in|au|ca|it|es|nl|se|no|fi|dk|pl|ua|kz|by)[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]*)" + ) + private val EMAIL_PATTERN = Patterns.EMAIL_ADDRESS + private val PHONE_PATTERN = Pattern.compile( + "\\+?[0-9][\\s\\-()0-9]{6,}[0-9]" + ) } + private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue + private var linksEnabled: Boolean = false + init { // Отключаем лишние отступы шрифта для корректного отображения emoji includeFontPadding = false } + /** + * 🔥 Установить цвет для ссылок + */ + fun setLinkColor(color: Int) { + linkColorValue = color + } + + /** + * 🔥 Включить/выключить кликабельные ссылки + */ + fun enableClickableLinks(enable: Boolean) { + linksEnabled = enable + if (enable) { + movementMethod = LinkMovementMethod.getInstance() + // Убираем highlight при клике + highlightColor = android.graphics.Color.TRANSPARENT + } else { + movementMethod = null + } + } + fun setTextWithEmojis(text: String) { // 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения val spannable = SpannableStringBuilder(text) @@ -449,8 +506,94 @@ class AppleEmojiTextView @JvmOverloads constructor( } } + // 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи + if (linksEnabled) { + addClickableLinks(spannable) + } + setText(spannable) } + + /** + * 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable + */ + private enum class LinkType { URL, EMAIL, PHONE } + + private fun addClickableLinks(spannable: SpannableStringBuilder) { + val textStr = spannable.toString() + + // Собираем все найденные ссылки + data class LinkMatch(val start: Int, val end: Int, val url: String, val type: LinkType) + val linkMatches = mutableListOf() + + // 1. Ищем URL + val urlMatcher = URL_PATTERN.matcher(textStr) + while (urlMatcher.find()) { + var url = urlMatcher.group() + // Добавляем https:// если нет протокола + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://$url" + } + linkMatches.add(LinkMatch(urlMatcher.start(), urlMatcher.end(), url, LinkType.URL)) + } + + // 2. Ищем Email + val emailMatcher = EMAIL_PATTERN.matcher(textStr) + while (emailMatcher.find()) { + val email = emailMatcher.group() + // Проверяем что не перекрывается с URL + val overlaps = linkMatches.any { + (emailMatcher.start() >= it.start && emailMatcher.start() < it.end) || + (emailMatcher.end() > it.start && emailMatcher.end() <= it.end) + } + if (!overlaps) { + linkMatches.add(LinkMatch(emailMatcher.start(), emailMatcher.end(), "mailto:$email", LinkType.EMAIL)) + } + } + + // 3. Ищем телефоны + val phoneMatcher = PHONE_PATTERN.matcher(textStr) + while (phoneMatcher.find()) { + val phone = phoneMatcher.group().replace("[\\s\\-()]".toRegex(), "") + // Проверяем что не перекрывается с другими ссылками + val overlaps = linkMatches.any { + (phoneMatcher.start() >= it.start && phoneMatcher.start() < it.end) || + (phoneMatcher.end() > it.start && phoneMatcher.end() <= it.end) + } + if (!overlaps && phone.length >= 7) { + linkMatches.add(LinkMatch(phoneMatcher.start(), phoneMatcher.end(), "tel:$phone", LinkType.PHONE)) + } + } + + // 4. Применяем ClickableSpan для каждой ссылки + for (link in linkMatches) { + val clickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (e: Exception) { + // Если не удалось открыть ссылку, игнорируем + e.printStackTrace() + } + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.color = linkColorValue + ds.isUnderlineText = false // Убираем подчёркивание (Telegram-style) + } + } + + spannable.setSpan( + clickableSpan, + link.start, + link.end, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } private fun loadEmojiBitmap(unified: String): Bitmap? { bitmapCache.get(unified)?.let { return it } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index 2c6211a..d7be721 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -158,6 +158,7 @@ private fun computeAvatarState( avatarSizeMinPx: Float, // Into notch size (24dp or notch width) hasAvatar: Boolean, // Notch info + notchCenterX: Float, // X position of front camera/notch notchCenterY: Float, notchRadiusPx: Float, // Telegram thresholds in pixels @@ -211,9 +212,20 @@ private fun computeAvatarState( val isNear = radius <= dp32 // ═══════════════════════════════════════════════════════════════ - // CENTER X - always screen center + // CENTER X - animate towards notch/camera position when collapsing + // Normal: screen center, Collapsed: notch center (front camera) // ═══════════════════════════════════════════════════════════════ - val centerX = screenWidthPx / 2f + val startX = screenWidthPx / 2f // Normal position = screen center + val endX = notchCenterX // Target = front camera position + + val centerX: Float = when { + // Pull-down expansion - stay at screen center + hasAvatar && expansionProgress > 0f -> screenWidthPx / 2f + // Collapsing - animate X towards notch/camera + collapseProgress > 0f -> lerpFloat(endX, startX, diff) + // Normal state - screen center + else -> screenWidthPx / 2f + } // ═══════════════════════════════════════════════════════════════ // CENTER Y - Telegram: avatarY = lerp(endY, startY, diff) @@ -380,7 +392,7 @@ fun ProfileMetaballOverlay( // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember // derivedStateOf автоматически отслеживает их как зависимости внутри лямбды // Только стабильные параметры (размеры экрана, notch info) как ключи remember - val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterY, notchRadiusPx) { + val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) { derivedStateOf { computeAvatarState( collapseProgress = collapseProgress, @@ -391,6 +403,7 @@ fun ProfileMetaballOverlay( avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, hasAvatar = hasAvatar, + notchCenterX = notchCenterX, notchCenterY = notchCenterY, notchRadiusPx = notchRadiusPx, dp40 = dp40, @@ -649,6 +662,7 @@ fun ProfileMetaballOverlayCompat( val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } // Fallback notch values for compat mode + val notchCenterX = screenWidthPx / 2f // Center of screen for compat mode val notchCenterY = statusBarHeightPx / 2f val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } @@ -661,7 +675,7 @@ fun ProfileMetaballOverlayCompat( // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember // derivedStateOf автоматически отслеживает их как зависимости - val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx) { + val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX) { derivedStateOf { computeAvatarState( collapseProgress = collapseProgress, @@ -672,6 +686,7 @@ fun ProfileMetaballOverlayCompat( avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, hasAvatar = hasAvatar, + notchCenterX = notchCenterX, notchCenterY = notchCenterY, notchRadiusPx = notchRadiusPx, dp40 = dp40,