feat: enhance versioning and avatar handling with dynamic properties and improved UI interactions
This commit is contained in:
@@ -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}" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user