feat: enhance versioning and avatar handling with dynamic properties and improved UI interactions

This commit is contained in:
2026-02-10 20:41:32 +05:00
parent bbaa04cda5
commit a0ef378909
12 changed files with 401 additions and 99 deletions

View File

@@ -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}" }

View File

@@ -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<Screen.ChatDetail>().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

View File

@@ -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<MessageAttachment> = emptyList()
)
// Сообщения для пересылки
@@ -120,4 +122,22 @@ object ForwardManager {
emptyList()
}
}
/**
* Атомарно получить forward-сообщения для конкретного чата и очистить pending state.
* Это повторяет desktop-подход "consume once" после перехода в целевой диалог.
*/
@Synchronized
fun consumeForwardMessagesForChat(publicKey: String): List<ForwardMessage> {
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
}
}

View File

@@ -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 =

View File

@@ -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(

View File

@@ -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<DialogUiModel> = 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)
)
}
}
}
}
}
}

View File

@@ -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))
}
)
}
}
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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