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") 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 { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"
compileSdk = 34 compileSdk = 34
@@ -13,8 +27,9 @@ android {
applicationId = "com.rosetta.messenger" applicationId = "com.rosetta.messenger"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = computedVersionCode
versionName = "1.0" versionName = computedVersionName
buildConfigField("String", "GIT_SHA", "\"$gitShortSha\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
@@ -52,7 +67,10 @@ android {
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { jvmTarget = "11" } kotlinOptions { jvmTarget = "11" }
buildFeatures { compose = true } buildFeatures {
compose = true
buildConfig = true
}
composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } composeOptions { kotlinCompilerExtensionVersion = "1.5.4" }
packaging { packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } 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.auth.AuthFlow
import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen 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.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.components.SwipeBackContainer
@@ -484,6 +485,7 @@ class MainActivity : FragmentActivity() {
*/ */
sealed class Screen { sealed class Screen {
data object Profile : Screen() data object Profile : Screen()
data object Requests : Screen()
data object Search : Screen() data object Search : Screen()
data class ChatDetail(val user: SearchUser) : Screen() data class ChatDetail(val user: SearchUser) : Screen()
data class OtherProfile(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 // Derived visibility — only triggers recomposition when THIS screen changes
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } 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 isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val chatDetailScreen by remember { val chatDetailScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() } derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() }
@@ -616,6 +619,8 @@ fun MainScreen(
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
androidx.lifecycle.viewmodel.compose.viewModel() androidx.lifecycle.viewmodel.compose.viewModel()
val profileState by profileViewModel.state.collectAsState() val profileState by profileViewModel.state.collectAsState()
val chatsListViewModel: com.rosetta.messenger.ui.chats.ChatsListViewModel =
androidx.lifecycle.viewmodel.compose.viewModel()
// Appearance: background blur color preference // Appearance: background blur color preference
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
@@ -682,6 +687,7 @@ fun MainScreen(
// TODO: Share invite link // TODO: Share invite link
}, },
onSearchClick = { pushScreen(Screen.Search) }, onSearchClick = { pushScreen(Screen.Search) },
onRequestsClick = { pushScreen(Screen.Requests) },
onNewChat = { onNewChat = {
// TODO: Show new chat screen // TODO: Show new chat screen
}, },
@@ -693,10 +699,29 @@ fun MainScreen(
onTogglePin = { opponentKey -> onTogglePin = { opponentKey ->
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) } mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
}, },
chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onLogout = onLogout 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 // Profile Screen — MUST be before sub-screens so it stays
// visible beneath them during swipe-back animation // visible beneath them during swipe-back animation

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.data package com.rosetta.messenger.data
import com.rosetta.messenger.network.MessageAttachment
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -27,7 +28,8 @@ object ForwardManager {
val timestamp: Long, val timestamp: Long,
val isOutgoing: Boolean, val isOutgoing: Boolean,
val senderPublicKey: String, // publicKey отправителя сообщения val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String // publicKey чата откуда пересылается val originalChatPublicKey: String, // publicKey чата откуда пересылается
val attachments: List<MessageAttachment> = emptyList()
) )
// Сообщения для пересылки // Сообщения для пересылки
@@ -120,4 +122,22 @@ object ForwardManager {
emptyList() 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 else
user.publicKey, user.publicKey,
originalChatPublicKey = originalChatPublicKey =
user.publicKey user.publicKey,
attachments =
msg.attachments
.filter {
it.type !=
AttachmentType
.MESSAGES
}
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
) )
} }
ForwardManager ForwardManager
@@ -1797,6 +1811,8 @@ fun ChatDetailScreen(
message, message,
isDarkTheme = isDarkTheme =
isDarkTheme, isDarkTheme,
isSelectionMode =
isSelectionMode,
showTail = showTail =
showTail, showTail,
isGroupStart = isGroupStart =

View File

@@ -500,7 +500,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния! // 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
// Это важно для правильного отображения forward сообщений сразу // Это важно для правильного отображения forward сообщений сразу
val forwardMessages = ForwardManager.getForwardMessagesForChat(publicKey) val forwardMessages = ForwardManager.consumeForwardMessagesForChat(publicKey)
val hasForward = forwardMessages.isNotEmpty() val hasForward = forwardMessages.isNotEmpty()
if (hasForward) {} if (hasForward) {}
@@ -543,12 +543,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = fm.text, text = fm.text,
timestamp = fm.timestamp, timestamp = fm.timestamp,
isOutgoing = fm.isOutgoing, isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey publicKey = fm.senderPublicKey,
attachments = fm.attachments
) )
} }
_isForwardMode.value = true _isForwardMode.value = true
// Очищаем ForwardManager после применения
ForwardManager.clear()
} else { } else {
// Сбрасываем forward state если нет forward сообщений // Сбрасываем forward state если нет forward сообщений
_replyMessages.value = emptyList() _replyMessages.value = emptyList()
@@ -1261,6 +1260,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attType = AttachmentType.fromInt(attJson.optInt("type", 0)) val attType = AttachmentType.fromInt(attJson.optInt("type", 0))
val attPreview = attJson.optString("preview", "") val attPreview = attJson.optString("preview", "")
val attBlob = attJson.optString("blob", "") val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0)
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add( replyAttachmentsFromJson.add(
@@ -1268,7 +1269,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attId, id = attId,
type = attType, type = attType,
preview = attPreview, preview = attPreview,
blob = attBlob blob = attBlob,
width = attWidth,
height = attHeight
) )
) )
} }
@@ -1412,7 +1415,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = msg.text, text = msg.text,
timestamp = msg.timestamp.time, timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing, 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 _isForwardMode.value = true
@@ -1572,6 +1579,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("id", att.id) put("id", att.id)
put("type", att.type.value) put("type", att.type.value)
put("preview", att.preview) put("preview", att.preview)
put("width", att.width)
put("height", att.height)
// Для IMAGE/FILE - blob не включаем (слишком большой) // Для IMAGE/FILE - blob не включаем (слишком большой)
// Для MESSAGES - включаем blob // Для MESSAGES - включаем blob
put( put(

View File

@@ -164,6 +164,7 @@ fun ChatsListScreen(
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onInviteFriendsClick: () -> Unit, onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit, onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar", backgroundBlurColorId: String = "avatar",
@@ -429,7 +430,7 @@ fun ChatsListScreen(
if (showRequestsScreen) return@pointerInput if (showRequestsScreen) return@pointerInput
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
val relaxedTouchSlop = viewConfiguration.touchSlop * 0.45f val relaxedTouchSlop = viewConfiguration.touchSlop * 0.8f
awaitEachGesture { awaitEachGesture {
val down = val down =
@@ -1062,10 +1063,31 @@ fun ChatsListScreen(
AnimatedContent( AnimatedContent(
targetState = showRequestsScreen, targetState = showRequestsScreen,
transitionSpec = { transitionSpec = {
fadeIn( if (targetState) {
animationSpec = tween(200) // Opening requests: slide in from right
) togetherWith slideInHorizontally(
fadeOut(animationSpec = tween(150)) 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" label = "RequestsTransition"
) { isRequestsScreen -> ) { isRequestsScreen ->
@@ -1163,11 +1185,12 @@ fun ChatsListScreen(
RequestsSection( RequestsSection(
count = count =
requestsCount, requestsCount,
requests =
requests,
isDarkTheme = isDarkTheme =
isDarkTheme, isDarkTheme,
onClick = { onClick = {
showRequestsScreen = onRequestsClick()
true
} }
) )
Divider( Divider(
@@ -2015,8 +2038,14 @@ fun SwipeableDialogItem(
} }
?: break ?: break
if (change.changedToUpIgnoreConsumed() if (change.changedToUpIgnoreConsumed()
) ) {
// Tap detected — finger went up before touchSlop
if (!passedSlop) {
change.consume()
onClick()
}
break break
}
val delta = change.positionChange() val delta = change.positionChange()
totalDragX += delta.x totalDragX += delta.x
@@ -2155,7 +2184,7 @@ fun SwipeableDialogItem(
isPinned = isPinned, isPinned = isPinned,
isBlocked = isBlocked, isBlocked = isBlocked,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onClick = onClick onClick = null // Tap handled by parent pointerInput
) )
// Сепаратор внутри контента // Сепаратор внутри контента
@@ -2178,7 +2207,7 @@ fun DialogItemContent(
isPinned: Boolean = false, isPinned: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onClick: () -> Unit onClick: (() -> Unit)? = null
) { ) {
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки // 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
@@ -2257,7 +2286,10 @@ fun DialogItemContent(
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(onClick = onClick) .then(
if (onClick != null) Modifier.clickable(onClick = onClick)
else Modifier
)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -2656,33 +2688,141 @@ fun TypingIndicatorSmall() {
} }
} }
/** 📬 Секция Requests - кнопка для перехода к списку запросов */ /** 📬 Секция Requests — Telegram-style chat item (как Archived Chats) */
@Composable @Composable
fun RequestsSection(count: Int, isDarkTheme: Boolean, onClick: () -> Unit) { fun RequestsSection(
val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6) count: Int,
val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6) 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( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( // Иконка — круглый аватар как в Telegram Archived Chats
text = "Requests +$count", Box(
fontSize = 16.sp, modifier =
fontWeight = FontWeight.SemiBold, Modifier.size(56.dp)
color = textColor .clip(CircleShape)
) .background(iconBgColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.MailForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
Icon( Spacer(modifier = Modifier.width(12.dp))
imageVector = TablerIcons.ChevronRight,
contentDescription = "Open requests", // Текст: название + последний отправитель
tint = arrowColor.copy(alpha = 0.6f), Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.size(24.dp) 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( fun MessageBubble(
message: ChatMessage, message: ChatMessage,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isSelectionMode: Boolean = false,
showTail: Boolean = true, showTail: Boolean = true,
isGroupStart: Boolean = false, isGroupStart: Boolean = false,
isSelected: Boolean = false, isSelected: Boolean = false,
@@ -307,6 +308,8 @@ fun MessageBubble(
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
else Color(0xFF2196F3) // Стандартный Material Blue для входящих else Color(0xFF2196F3) // Стандартный Material Blue для входящих
} }
val linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = if (isSelectionMode) onClick else null
val timeColor = val timeColor =
remember(message.isOutgoing, isDarkTheme) { remember(message.isOutgoing, isDarkTheme) {
@@ -705,6 +708,10 @@ fun MessageBubble(
fontSize = 16.sp, fontSize = 16.sp,
linkColor = linkColor =
linkColor, linkColor,
enableLinks =
linksEnabled,
onClick =
textClickHandler,
onLongClick = onLongClick =
onLongClick // 🔥 Long press для selection onLongClick // 🔥 Long press для selection
) )
@@ -786,6 +793,8 @@ fun MessageBubble(
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor, linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick = onLongClick =
onLongClick // 🔥 onLongClick // 🔥
// Long // Long
@@ -861,6 +870,8 @@ fun MessageBubble(
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp,
linkColor = linkColor, linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick = onLongClick =
onLongClick // 🔥 onLongClick // 🔥
// Long // Long

View File

@@ -399,7 +399,9 @@ fun MessageInputBar(
ReplyImagePreview( ReplyImagePreview(
attachment = imageAttachment, attachment = imageAttachment,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
senderPublicKey = if (msg.isOutgoing) myPublicKey else opponentPublicKey, senderPublicKey = msg.publicKey.ifEmpty {
if (msg.isOutgoing) myPublicKey else opponentPublicKey
},
recipientPrivateKey = myPrivateKey recipientPrivateKey = myPrivateKey
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View File

@@ -335,6 +335,7 @@ fun AppleEmojiText(
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 linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble) onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
) { ) {
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
@@ -370,9 +371,12 @@ fun AppleEmojiText(
setLinkColor(linkColor.toArgb()) setLinkColor(linkColor.toArgb())
enableClickableLinks(true, onLongClick) enableClickableLinks(true, onLongClick)
} else { } else {
// 🔥 Даже без ссылок поддерживаем long press // 🔥 ВАЖНО: в selection mode полностью отключаем LinkMovementMethod,
onLongClickCallback = onLongClick // иначе тапы по тексту могут "съедаться" и не доходить до onClick.
enableClickableLinks(false, onLongClick)
} }
// 🔥 Поддержка обычного tap (например, для selection mode)
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
} }
}, },
update = { view -> update = { view ->
@@ -389,9 +393,12 @@ fun AppleEmojiText(
view.setLinkColor(linkColor.toArgb()) view.setLinkColor(linkColor.toArgb())
view.enableClickableLinks(true, onLongClick) view.enableClickableLinks(true, onLongClick)
} else { } else {
// 🔥 Даже без ссылок поддерживаем long press // 🔥 ВАЖНО: в selection mode полностью отключаем LinkMovementMethod,
view.onLongClickCallback = onLongClick // иначе тапы по тексту могут "съедаться" и не доходить до onClick.
view.enableClickableLinks(false, onLongClick)
} }
// 🔥 Обновляем tap callback, чтобы не было stale lambda
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
}, },
modifier = modifier modifier = modifier
) )

View File

@@ -152,7 +152,10 @@ private fun isCenteredTopCutout(
if (notchInfo == null || notchInfo.bounds.width() <= 0f || notchInfo.bounds.height() <= 0f) { if (notchInfo == null || notchInfo.bounds.width() <= 0f || notchInfo.bounds.height() <= 0f) {
return false 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 return abs(notchInfo.bounds.centerX() - screenWidthPx / 2f) <= tolerancePx
} }
@@ -211,7 +214,6 @@ private fun computeAvatarState(
avatarSizeMinPx: Float, // Into notch size (24dp or notch width) avatarSizeMinPx: Float, // Into notch size (24dp or notch width)
hasAvatar: Boolean, hasAvatar: Boolean,
// Notch info // Notch info
notchCenterX: Float, // X position of front camera/notch
notchCenterY: Float, notchCenterY: Float,
notchRadiusPx: Float, notchRadiusPx: Float,
// Telegram thresholds in pixels // Telegram thresholds in pixels
@@ -265,21 +267,8 @@ private fun computeAvatarState(
val isDrawing = radius <= dp40 val isDrawing = radius <= dp40
val isNear = radius <= dp32 val isNear = radius <= dp32
// ═══════════════════════════════════════════════════════════════ // Always lock X to screen center to avoid OEM cutout offset issues on some devices.
// CENTER X - animate towards notch/camera position when collapsing val centerX: Float = screenWidthPx / 2f
// 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
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// CENTER Y - Telegram: avatarY = lerp(endY, startY, diff) // 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) // Always use true screen center for X target to keep droplet perfectly centered.
// If notch is off-center (corner notch), use screen center instead val notchCenterX = screenWidthPx / 2f
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
}
}
val notchCenterY = remember(notchInfo, hasCenteredNotch, statusBarHeightPx) { val notchCenterY = remember(notchInfo, hasCenteredNotch, statusBarHeightPx) {
resolveSafeNotchCenterY( resolveSafeNotchCenterY(
@@ -493,7 +473,6 @@ fun ProfileMetaballOverlay(
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar, hasAvatar = hasAvatar,
notchCenterX = notchCenterX,
notchCenterY = notchCenterY, notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx, notchRadiusPx = notchRadiusPx,
dp40 = dp40, dp40 = dp40,
@@ -596,19 +575,13 @@ fun ProfileMetaballOverlay(
// Draw target shape at top (notch or black bar fallback) // Draw target shape at top (notch or black bar fallback)
if (showConnector) { if (showConnector) {
blackPaint.alpha = connectorPaintAlpha blackPaint.alpha = connectorPaintAlpha
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { if (hasRealNotch && notchInfo != null) {
nativeCanvas.drawCircle( nativeCanvas.drawCircle(
notchCenterX, notchCenterX,
notchCenterY, notchCenterY,
notchRadiusPx, notchRadiusPx,
blackPaint 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 { } else {
// No notch fallback: full-width black bar at top // No notch fallback: full-width black bar at top
// Like Telegram's ProfileGooeyView when notchInfo == null // Like Telegram's ProfileGooeyView when notchInfo == null
@@ -798,7 +771,6 @@ fun ProfileMetaballOverlayCompat(
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar, hasAvatar = hasAvatar,
notchCenterX = notchCenterX,
notchCenterY = notchCenterY, notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx, notchRadiusPx = notchRadiusPx,
dp40 = dp40, dp40 = dp40,
@@ -943,9 +915,8 @@ fun ProfileMetaballOverlayCpu(
with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
} }
} }
val notchCenterX = remember(notchInfo, screenWidthPx) { // Always use true screen center for X target to keep droplet perfectly centered.
if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f val notchCenterX = screenWidthPx / 2f
}
val notchCenterY = remember(notchInfo, hasRealNotch, statusBarHeightPx, blackBarHeightPx) { val notchCenterY = remember(notchInfo, hasRealNotch, statusBarHeightPx, blackBarHeightPx) {
resolveSafeNotchCenterY( resolveSafeNotchCenterY(
notchInfo = notchInfo, notchInfo = notchInfo,
@@ -973,7 +944,6 @@ fun ProfileMetaballOverlayCpu(
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar, hasAvatar = hasAvatar,
notchCenterX = notchCenterX,
notchCenterY = notchCenterY, notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx, notchRadiusPx = notchRadiusPx,
dp40 = dp40, dp34 = dp34, dp32 = dp32, dp18 = dp18, dp22 = dp22, dp40 = dp40, dp34 = dp34, dp32 = dp32, dp18 = dp18, dp22 = dp22,
@@ -1081,19 +1051,13 @@ fun ProfileMetaballOverlayCpu(
// Draw target (notch or black bar) // Draw target (notch or black bar)
if (showConnector) { if (showConnector) {
blackPaint.alpha = connectorPaintAlpha blackPaint.alpha = connectorPaintAlpha
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { if (hasRealNotch && notchInfo != null) {
val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
offscreenCanvas.drawCircle( offscreenCanvas.drawCircle(
notchInfo.bounds.centerX(), notchCenterX,
notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f, notchCenterY,
rad, blackPaint 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 { } else {
// No notch: draw black bar // No notch: draw black bar
offscreenCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint) 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 compose.icons.tablericons.* import compose.icons.tablericons.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.BuildConfig
@Composable @Composable
fun UpdatesScreen( fun UpdatesScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit 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 backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -113,7 +117,7 @@ fun UpdatesScreen(
color = textColor color = textColor
) )
Text( Text(
text = "1.0.0", text = versionName,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = secondaryTextColor color = secondaryTextColor
@@ -132,7 +136,7 @@ fun UpdatesScreen(
color = textColor color = textColor
) )
Text( Text(
text = "100", text = buildNumber,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = secondaryTextColor color = secondaryTextColor