From a0ef37890942941629f84034424e62e288be2c3b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 10 Feb 2026 20:41:32 +0500 Subject: [PATCH] feat: enhance versioning and avatar handling with dynamic properties and improved UI interactions --- app/build.gradle.kts | 24 ++- .../com/rosetta/messenger/MainActivity.kt | 25 +++ .../rosetta/messenger/data/ForwardManager.kt | 22 +- .../messenger/ui/chats/ChatDetailScreen.kt | 18 +- .../messenger/ui/chats/ChatViewModel.kt | 21 +- .../messenger/ui/chats/ChatsListScreen.kt | 198 +++++++++++++++--- .../messenger/ui/chats/RequestsListScreen.kt | 86 ++++++++ .../chats/components/ChatDetailComponents.kt | 11 + .../ui/chats/input/ChatDetailInput.kt | 4 +- .../ui/components/AppleEmojiEditText.kt | 15 +- .../metaball/ProfileMetaballOverlay.kt | 68 ++---- .../messenger/ui/settings/UpdatesScreen.kt | 8 +- 12 files changed, 401 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a23f80..4c2843c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,20 @@ plugins { id("com.google.gms.google-services") } +fun safeGitOutput(vararg args: String): String? { + return runCatching { + providers.exec { commandLine("git", *args) }.standardOutput.asText.get().trim().ifBlank { null } + }.getOrNull() +} + +val versionBase = providers.gradleProperty("ROSETTA_VERSION_BASE").orElse("1.0") +val explicitVersionCode = providers.gradleProperty("ROSETTA_VERSION_CODE").orNull?.toIntOrNull() +val explicitVersionName = providers.gradleProperty("ROSETTA_VERSION_NAME").orNull +val gitCommitCount = safeGitOutput("rev-list", "--count", "HEAD")?.toIntOrNull() +val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" +val computedVersionCode = (explicitVersionCode ?: gitCommitCount ?: 1).coerceAtLeast(1) +val computedVersionName = explicitVersionName ?: "${versionBase.get()}.$computedVersionCode" + android { namespace = "com.rosetta.messenger" compileSdk = 34 @@ -13,8 +27,9 @@ android { applicationId = "com.rosetta.messenger" minSdk = 24 targetSdk = 34 - versionCode = 1 - versionName = "1.0" + versionCode = computedVersionCode + versionName = computedVersionName + buildConfigField("String", "GIT_SHA", "\"$gitShortSha\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } @@ -52,7 +67,10 @@ android { targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } - buildFeatures { compose = true } + buildFeatures { + compose = true + buildConfig = true + } composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 47994ed..41d09f3 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -39,6 +39,7 @@ import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen +import com.rosetta.messenger.ui.chats.RequestsListScreen import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.SwipeBackContainer @@ -484,6 +485,7 @@ class MainActivity : FragmentActivity() { */ sealed class Screen { data object Profile : Screen() + data object Requests : Screen() data object Search : Screen() data class ChatDetail(val user: SearchUser) : Screen() data class OtherProfile(val user: SearchUser) : Screen() @@ -564,6 +566,7 @@ fun MainScreen( // Derived visibility — only triggers recomposition when THIS screen changes val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } + val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } } val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } val chatDetailScreen by remember { derivedStateOf { navStack.filterIsInstance().lastOrNull() } @@ -616,6 +619,8 @@ fun MainScreen( val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() val profileState by profileViewModel.state.collectAsState() + val chatsListViewModel: com.rosetta.messenger.ui.chats.ChatsListViewModel = + androidx.lifecycle.viewmodel.compose.viewModel() // Appearance: background blur color preference val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } @@ -682,6 +687,7 @@ fun MainScreen( // TODO: Share invite link }, onSearchClick = { pushScreen(Screen.Search) }, + onRequestsClick = { pushScreen(Screen.Requests) }, onNewChat = { // TODO: Show new chat screen }, @@ -693,10 +699,29 @@ fun MainScreen( onTogglePin = { opponentKey -> mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } }, + chatsViewModel = chatsListViewModel, avatarRepository = avatarRepository, onLogout = onLogout ) + SwipeBackContainer( + isVisible = isRequestsVisible, + onBack = { navStack = navStack.filterNot { it is Screen.Requests } }, + isDarkTheme = isDarkTheme + ) { + RequestsListScreen( + isDarkTheme = isDarkTheme, + chatsViewModel = chatsListViewModel, + onBack = { navStack = navStack.filterNot { it is Screen.Requests } }, + onUserSelect = { selectedRequestUser -> + navStack = + navStack.filterNot { + it is Screen.Requests || it is Screen.ChatDetail + } + Screen.ChatDetail(selectedRequestUser) + } + ) + } + // ═══════════════════════════════════════════════════════════ // Profile Screen — MUST be before sub-screens so it stays // visible beneath them during swipe-back animation diff --git a/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt b/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt index 4026cde..89bb2a8 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.data +import com.rosetta.messenger.network.MessageAttachment import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,7 +28,8 @@ object ForwardManager { val timestamp: Long, val isOutgoing: Boolean, val senderPublicKey: String, // publicKey отправителя сообщения - val originalChatPublicKey: String // publicKey чата откуда пересылается + val originalChatPublicKey: String, // publicKey чата откуда пересылается + val attachments: List = emptyList() ) // Сообщения для пересылки @@ -120,4 +122,22 @@ object ForwardManager { emptyList() } } + + /** + * Атомарно получить forward-сообщения для конкретного чата и очистить pending state. + * Это повторяет desktop-подход "consume once" после перехода в целевой диалог. + */ + @Synchronized + fun consumeForwardMessagesForChat(publicKey: String): List { + val selectedKey = _selectedChatPublicKey.value + val pending = _forwardMessages.value + if (selectedKey != publicKey || pending.isEmpty()) { + return emptyList() + } + + _forwardMessages.value = emptyList() + _selectedChatPublicKey.value = null + _showChatPicker.value = false + return pending + } } 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 1831223..5eafca2 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 @@ -1389,7 +1389,21 @@ fun ChatDetailScreen( else user.publicKey, originalChatPublicKey = - user.publicKey + user.publicKey, + attachments = + msg.attachments + .filter { + it.type != + AttachmentType + .MESSAGES + } + .map { + attachment -> + attachment.copy( + localUri = + "" + ) + } ) } ForwardManager @@ -1797,6 +1811,8 @@ fun ChatDetailScreen( message, isDarkTheme = isDarkTheme, + isSelectionMode = + isSelectionMode, showTail = showTail, isGroupStart = 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 1b57e2c..016c882 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 @@ -500,7 +500,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния! // Это важно для правильного отображения forward сообщений сразу - val forwardMessages = ForwardManager.getForwardMessagesForChat(publicKey) + val forwardMessages = ForwardManager.consumeForwardMessagesForChat(publicKey) val hasForward = forwardMessages.isNotEmpty() if (hasForward) {} @@ -543,12 +543,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { text = fm.text, timestamp = fm.timestamp, isOutgoing = fm.isOutgoing, - publicKey = fm.senderPublicKey + publicKey = fm.senderPublicKey, + attachments = fm.attachments ) } _isForwardMode.value = true - // Очищаем ForwardManager после применения - ForwardManager.clear() } else { // Сбрасываем forward state если нет forward сообщений _replyMessages.value = emptyList() @@ -1261,6 +1260,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val attType = AttachmentType.fromInt(attJson.optInt("type", 0)) val attPreview = attJson.optString("preview", "") val attBlob = attJson.optString("blob", "") + val attWidth = attJson.optInt("width", 0) + val attHeight = attJson.optInt("height", 0) if (attId.isNotEmpty()) { replyAttachmentsFromJson.add( @@ -1268,7 +1269,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { id = attId, type = attType, preview = attPreview, - blob = attBlob + blob = attBlob, + width = attWidth, + height = attHeight ) ) } @@ -1412,7 +1415,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { text = msg.text, timestamp = msg.timestamp.time, isOutgoing = msg.isOutgoing, - publicKey = if (msg.isOutgoing) sender else opponent + publicKey = if (msg.isOutgoing) sender else opponent, + attachments = + msg.attachments + .filter { it.type != AttachmentType.MESSAGES } + .map { it.copy(localUri = "") } ) } _isForwardMode.value = true @@ -1572,6 +1579,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { put("id", att.id) put("type", att.type.value) put("preview", att.preview) + put("width", att.width) + put("height", att.height) // Для IMAGE/FILE - blob не включаем (слишком большой) // Для MESSAGES - включаем blob put( 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 ca82c03..44f4ee0 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 @@ -164,6 +164,7 @@ fun ChatsListScreen( onSettingsClick: () -> Unit, onInviteFriendsClick: () -> Unit, onSearchClick: () -> Unit, + onRequestsClick: () -> Unit = {}, onNewChat: () -> Unit, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, backgroundBlurColorId: String = "avatar", @@ -429,7 +430,7 @@ fun ChatsListScreen( if (showRequestsScreen) return@pointerInput val velocityTracker = VelocityTracker() - val relaxedTouchSlop = viewConfiguration.touchSlop * 0.45f + val relaxedTouchSlop = viewConfiguration.touchSlop * 0.8f awaitEachGesture { val down = @@ -1062,10 +1063,31 @@ fun ChatsListScreen( AnimatedContent( targetState = showRequestsScreen, transitionSpec = { - fadeIn( - animationSpec = tween(200) - ) togetherWith - fadeOut(animationSpec = tween(150)) + if (targetState) { + // Opening requests: slide in from right + slideInHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> fullWidth } + fadeIn( + animationSpec = tween(200) + ) togetherWith + slideOutHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> -fullWidth / 4 } + fadeOut( + animationSpec = tween(150) + ) + } else { + // Closing requests: slide out to right + slideInHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> -fullWidth / 4 } + fadeIn( + animationSpec = tween(200) + ) togetherWith + slideOutHorizontally( + animationSpec = tween(280, easing = FastOutSlowInEasing) + ) { fullWidth -> fullWidth } + fadeOut( + animationSpec = tween(150) + ) + } }, label = "RequestsTransition" ) { isRequestsScreen -> @@ -1163,11 +1185,12 @@ fun ChatsListScreen( RequestsSection( count = requestsCount, + requests = + requests, isDarkTheme = isDarkTheme, onClick = { - showRequestsScreen = - true + onRequestsClick() } ) Divider( @@ -2015,8 +2038,14 @@ fun SwipeableDialogItem( } ?: break if (change.changedToUpIgnoreConsumed() - ) + ) { + // Tap detected — finger went up before touchSlop + if (!passedSlop) { + change.consume() + onClick() + } break + } val delta = change.positionChange() totalDragX += delta.x @@ -2155,7 +2184,7 @@ fun SwipeableDialogItem( isPinned = isPinned, isBlocked = isBlocked, avatarRepository = avatarRepository, - onClick = onClick + onClick = null // Tap handled by parent pointerInput ) // Сепаратор внутри контента @@ -2178,7 +2207,7 @@ fun DialogItemContent( isPinned: Boolean = false, isBlocked: Boolean = false, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, - onClick: () -> Unit + onClick: (() -> Unit)? = null ) { // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } @@ -2257,7 +2286,10 @@ fun DialogItemContent( Row( modifier = Modifier.fillMaxWidth() - .clickable(onClick = onClick) + .then( + if (onClick != null) Modifier.clickable(onClick = onClick) + else Modifier + ) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -2656,33 +2688,141 @@ fun TypingIndicatorSmall() { } } -/** 📬 Секция Requests - кнопка для перехода к списку запросов */ +/** 📬 Секция Requests — Telegram-style chat item (как Archived Chats) */ @Composable -fun RequestsSection(count: Int, isDarkTheme: Boolean, onClick: () -> Unit) { - val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6) - val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6) +fun RequestsSection( + count: Int, + requests: List = emptyList(), + isDarkTheme: Boolean, + onClick: () -> Unit +) { + val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } + val secondaryTextColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + val iconBgColor = + remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFC7C7CC) } + + // Последний запрос — показываем имя отправителя как subtitle + val lastRequest = remember(requests) { requests.firstOrNull() } + val subtitle = remember(lastRequest) { + when { + lastRequest == null -> "" + lastRequest.opponentTitle.isNotEmpty() && + lastRequest.opponentTitle != lastRequest.opponentKey && + lastRequest.opponentTitle != lastRequest.opponentKey.take(7) -> + lastRequest.opponentTitle + lastRequest.opponentUsername.isNotEmpty() -> + "@${lastRequest.opponentUsername}" + else -> lastRequest.opponentKey.take(7) + } + } Row( modifier = Modifier.fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Requests +$count", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor - ) + // Иконка — круглый аватар как в Telegram Archived Chats + Box( + modifier = + Modifier.size(56.dp) + .clip(CircleShape) + .background(iconBgColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.MailForward, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(26.dp) + ) + } - Icon( - imageVector = TablerIcons.ChevronRight, - contentDescription = "Open requests", - tint = arrowColor.copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) + Spacer(modifier = Modifier.width(12.dp)) + + // Текст: название + последний отправитель + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Requests", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + if (subtitle.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = subtitle, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + // Badge с количеством + if (count > 0) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .defaultMinSize(minWidth = 22.dp, minHeight = 22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(iconBgColor), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } + } + } else if (count > 0) { + // Если нет subtitle но есть count + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Box( + modifier = + Modifier + .defaultMinSize(minWidth = 22.dp, minHeight = 22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(iconBgColor), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } + } + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt new file mode 100644 index 0000000..269a62d --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -0,0 +1,86 @@ +package com.rosetta.messenger.ui.chats + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.rosetta.messenger.network.SearchUser +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowLeft + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RequestsListScreen( + isDarkTheme: Boolean, + chatsViewModel: ChatsListViewModel, + onBack: () -> Unit, + onUserSelect: (SearchUser) -> Unit +) { + val chatsState by chatsViewModel.chatsState.collectAsState() + val requests = chatsState.requests + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + val textColor = if (isDarkTheme) Color.White else Color.Black + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = TablerIcons.ArrowLeft, + contentDescription = "Back", + tint = PrimaryBlue + ) + } + }, + title = { + Text( + text = "Requests", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = textColor + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = backgroundColor, + scrolledContainerColor = backgroundColor, + navigationIconContentColor = textColor, + titleContentColor = textColor + ) + ) + }, + containerColor = backgroundColor + ) { paddingValues -> + Box( + modifier = + Modifier.fillMaxSize() + .background(backgroundColor) + .padding(paddingValues) + ) { + RequestsScreen( + requests = requests, + isDarkTheme = isDarkTheme, + onBack = onBack, + onRequestClick = { request -> + onUserSelect(chatsViewModel.dialogToSearchUser(request)) + } + ) + } + } +} 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 22a6dbb..991b6ff 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 @@ -240,6 +240,7 @@ fun TypingIndicator(isDarkTheme: Boolean) { fun MessageBubble( message: ChatMessage, isDarkTheme: Boolean, + isSelectionMode: Boolean = false, showTail: Boolean = true, isGroupStart: Boolean = false, isSelected: Boolean = false, @@ -307,6 +308,8 @@ fun MessageBubble( if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне else Color(0xFF2196F3) // Стандартный Material Blue для входящих } + val linksEnabled = !isSelectionMode + val textClickHandler: (() -> Unit)? = if (isSelectionMode) onClick else null val timeColor = remember(message.isOutgoing, isDarkTheme) { @@ -705,6 +708,10 @@ fun MessageBubble( fontSize = 16.sp, linkColor = linkColor, + enableLinks = + linksEnabled, + onClick = + textClickHandler, onLongClick = onLongClick // 🔥 Long press для selection ) @@ -786,6 +793,8 @@ fun MessageBubble( color = textColor, fontSize = 17.sp, linkColor = linkColor, + enableLinks = linksEnabled, + onClick = textClickHandler, onLongClick = onLongClick // 🔥 // Long @@ -861,6 +870,8 @@ fun MessageBubble( color = textColor, fontSize = 17.sp, linkColor = linkColor, + enableLinks = linksEnabled, + onClick = textClickHandler, onLongClick = onLongClick // 🔥 // Long 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 11068ab..60ef793 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 @@ -399,7 +399,9 @@ fun MessageInputBar( ReplyImagePreview( attachment = imageAttachment, modifier = Modifier.size(36.dp), - senderPublicKey = if (msg.isOutgoing) myPublicKey else opponentPublicKey, + senderPublicKey = msg.publicKey.ifEmpty { + if (msg.isOutgoing) myPublicKey else opponentPublicKey + }, recipientPrivateKey = myPrivateKey ) Spacer(modifier = Modifier.width(4.dp)) 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 3a1e386..f583e8a 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 @@ -335,6 +335,7 @@ fun AppleEmojiText( overflow: android.text.TextUtils.TruncateAt? = null, linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки + onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble) ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f @@ -370,9 +371,12 @@ fun AppleEmojiText( setLinkColor(linkColor.toArgb()) enableClickableLinks(true, onLongClick) } else { - // 🔥 Даже без ссылок поддерживаем long press - onLongClickCallback = onLongClick + // 🔥 ВАЖНО: в selection mode полностью отключаем LinkMovementMethod, + // иначе тапы по тексту могут "съедаться" и не доходить до onClick. + enableClickableLinks(false, onLongClick) } + // 🔥 Поддержка обычного tap (например, для selection mode) + setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } }) } }, update = { view -> @@ -389,9 +393,12 @@ fun AppleEmojiText( view.setLinkColor(linkColor.toArgb()) view.enableClickableLinks(true, onLongClick) } else { - // 🔥 Даже без ссылок поддерживаем long press - view.onLongClickCallback = onLongClick + // 🔥 ВАЖНО: в selection mode полностью отключаем LinkMovementMethod, + // иначе тапы по тексту могут "съедаться" и не доходить до onClick. + view.enableClickableLinks(false, onLongClick) } + // 🔥 Обновляем tap callback, чтобы не было stale lambda + view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } }) }, modifier = modifier ) 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 5ec0bb7..aea5123 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 @@ -152,7 +152,10 @@ private fun isCenteredTopCutout( if (notchInfo == null || notchInfo.bounds.width() <= 0f || notchInfo.bounds.height() <= 0f) { return false } - val tolerancePx = screenWidthPx * 0.20f + if (notchInfo.gravity != Gravity.CENTER) { + return false + } + val tolerancePx = screenWidthPx * 0.10f return abs(notchInfo.bounds.centerX() - screenWidthPx / 2f) <= tolerancePx } @@ -211,7 +214,6 @@ 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 @@ -265,21 +267,8 @@ private fun computeAvatarState( val isDrawing = radius <= dp40 val isNear = radius <= dp32 - // ═══════════════════════════════════════════════════════════════ - // CENTER X - animate towards notch/camera position when collapsing - // Normal: screen center, Collapsed: notch center (front camera) - // ═══════════════════════════════════════════════════════════════ - 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 - } + // Always lock X to screen center to avoid OEM cutout offset issues on some devices. + val centerX: Float = screenWidthPx / 2f // ═══════════════════════════════════════════════════════════════ // CENTER Y - Telegram: avatarY = lerp(endY, startY, diff) @@ -450,17 +439,8 @@ fun ProfileMetaballOverlay( } } - // Notch center position - ONLY use if notch is centered (like front camera) - // If notch is off-center (corner notch), use screen center instead - val notchCenterX = remember(notchInfo, screenWidthPx, hasCenteredNotch) { - if (hasCenteredNotch && notchInfo != null) { - // Centered notch (like Dynamic Island or punch-hole camera) - notchInfo.bounds.centerX() - } else { - // No notch or off-center notch - always use screen center - screenWidthPx / 2f - } - } + // Always use true screen center for X target to keep droplet perfectly centered. + val notchCenterX = screenWidthPx / 2f val notchCenterY = remember(notchInfo, hasCenteredNotch, statusBarHeightPx) { resolveSafeNotchCenterY( @@ -493,7 +473,6 @@ fun ProfileMetaballOverlay( avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, hasAvatar = hasAvatar, - notchCenterX = notchCenterX, notchCenterY = notchCenterY, notchRadiusPx = notchRadiusPx, dp40 = dp40, @@ -596,19 +575,13 @@ fun ProfileMetaballOverlay( // Draw target shape at top (notch or black bar fallback) if (showConnector) { blackPaint.alpha = connectorPaintAlpha - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { + if (hasRealNotch && notchInfo != null) { nativeCanvas.drawCircle( notchCenterX, notchCenterY, notchRadiusPx, blackPaint ) - } else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { - nativeCanvas.drawPath(notchInfo.path, blackPaint) - } else if (hasRealNotch && notchInfo != null) { - val bounds = notchInfo.bounds - val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f - nativeCanvas.drawRoundRect(bounds, rad, rad, blackPaint) } else { // No notch fallback: full-width black bar at top // Like Telegram's ProfileGooeyView when notchInfo == null @@ -798,7 +771,6 @@ fun ProfileMetaballOverlayCompat( avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, hasAvatar = hasAvatar, - notchCenterX = notchCenterX, notchCenterY = notchCenterY, notchRadiusPx = notchRadiusPx, dp40 = dp40, @@ -943,9 +915,8 @@ fun ProfileMetaballOverlayCpu( with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } } } - val notchCenterX = remember(notchInfo, screenWidthPx) { - if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f - } + // Always use true screen center for X target to keep droplet perfectly centered. + val notchCenterX = screenWidthPx / 2f val notchCenterY = remember(notchInfo, hasRealNotch, statusBarHeightPx, blackBarHeightPx) { resolveSafeNotchCenterY( notchInfo = notchInfo, @@ -973,7 +944,6 @@ fun ProfileMetaballOverlayCpu( avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, hasAvatar = hasAvatar, - notchCenterX = notchCenterX, notchCenterY = notchCenterY, notchRadiusPx = notchRadiusPx, dp40 = dp40, dp34 = dp34, dp32 = dp32, dp18 = dp18, dp22 = dp22, @@ -1081,19 +1051,13 @@ fun ProfileMetaballOverlayCpu( // Draw target (notch or black bar) if (showConnector) { blackPaint.alpha = connectorPaintAlpha - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { - val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + if (hasRealNotch && notchInfo != null) { offscreenCanvas.drawCircle( - notchInfo.bounds.centerX(), - notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f, - rad, blackPaint + notchCenterX, + notchCenterY, + notchRadiusPx, + blackPaint ) - } else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { - offscreenCanvas.drawPath(notchInfo.path, blackPaint) - } else if (hasRealNotch && notchInfo != null) { - val bounds = notchInfo.bounds - val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f - offscreenCanvas.drawRoundRect(bounds, rad, rad, blackPaint) } else { // No notch: draw black bar offscreenCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt index d38185b..178a1b1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt @@ -10,18 +10,22 @@ import androidx.compose.material3.* import compose.icons.TablerIcons import compose.icons.tablericons.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember 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.unit.dp import androidx.compose.ui.unit.sp +import com.rosetta.messenger.BuildConfig @Composable fun UpdatesScreen( isDarkTheme: Boolean, onBack: () -> Unit ) { + val versionName = remember { BuildConfig.VERSION_NAME } + val buildNumber = remember { BuildConfig.VERSION_CODE.toString() } 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 @@ -113,7 +117,7 @@ fun UpdatesScreen( color = textColor ) Text( - text = "1.0.0", + text = versionName, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = secondaryTextColor @@ -132,7 +136,7 @@ fun UpdatesScreen( color = textColor ) Text( - text = "100", + text = buildNumber, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = secondaryTextColor