Релиз 1.3.4: sticky-плашка звонка и поиск сообщений в диалоге
All checks were successful
Android Kernel Build / build (push) Successful in 19m40s
All checks were successful
Android Kernel Build / build (push) Successful in 19m40s
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.3.4
|
||||
|
||||
### Звонки и UI
|
||||
- Реализован Telegram-style фон звонка в приложении: full-screen звонок теперь можно свернуть в закрепленную верхнюю плашку в чат-листе.
|
||||
- Плашка звонка перенесена внутрь `ChatsListScreen` и ведет обратно в экран звонка по нажатию.
|
||||
- Обновлен UI звонка: иконка сворачивания в стиле Telegram, улучшено поведение call overlay.
|
||||
- Исправлено автоматическое скрытие клавиатуры при открытии экрана звонка.
|
||||
|
||||
### Поиск в диалоге
|
||||
- В kebab-меню каждого чата добавлен пункт `Search`.
|
||||
- Добавлен встроенный поиск сообщений внутри текущего диалога (через локальный индекс `message_search_index` и `dialog_key`).
|
||||
- Добавлена навигация по результатам (`prev/next`) со скроллом и подсветкой найденного сообщения.
|
||||
|
||||
## 1.3.3
|
||||
|
||||
### E2EE, чаты и производительность
|
||||
|
||||
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.3.3"
|
||||
val rosettaVersionCode = 35 // Increment on each release
|
||||
val rosettaVersionName = "1.3.4"
|
||||
val rosettaVersionCode = 36 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -38,6 +39,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.CallActionResult
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
@@ -594,6 +596,7 @@ fun MainScreen(
|
||||
val rootView = LocalView.current
|
||||
val callScope = rememberCoroutineScope()
|
||||
val callUiState by CallManager.state.collectAsState()
|
||||
var isCallOverlayExpanded by remember { mutableStateOf(true) }
|
||||
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
||||
var pendingIncomingAccept by remember { mutableStateOf(false) }
|
||||
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
|
||||
@@ -763,6 +766,14 @@ fun MainScreen(
|
||||
CallManager.bindAccount(accountPublicKey)
|
||||
}
|
||||
|
||||
LaunchedEffect(callUiState.isVisible) {
|
||||
if (callUiState.isVisible) {
|
||||
isCallOverlayExpanded = true
|
||||
} else {
|
||||
isCallOverlayExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(callUiState.isVisible) {
|
||||
if (callUiState.isVisible) {
|
||||
focusManager.clearFocus(force = true)
|
||||
@@ -776,6 +787,15 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram-style behavior: while call screen is open, Back should minimize call to top banner.
|
||||
BackHandler(
|
||||
enabled = callUiState.isVisible &&
|
||||
isCallOverlayExpanded &&
|
||||
callUiState.phase != CallPhase.INCOMING
|
||||
) {
|
||||
isCallOverlayExpanded = false
|
||||
}
|
||||
|
||||
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||
if (accountPublicKey.isNotBlank()) {
|
||||
val accountManager = AccountManager(context)
|
||||
@@ -1044,6 +1064,9 @@ fun MainScreen(
|
||||
},
|
||||
chatsViewModel = chatsListViewModel,
|
||||
avatarRepository = avatarRepository,
|
||||
callUiState = callUiState,
|
||||
isCallOverlayExpanded = isCallOverlayExpanded,
|
||||
onOpenCallOverlay = { isCallOverlayExpanded = true },
|
||||
onAddAccount = {
|
||||
onAddAccount()
|
||||
},
|
||||
@@ -1581,11 +1604,17 @@ fun MainScreen(
|
||||
state = callUiState,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING,
|
||||
onAccept = { acceptCallWithPermission() },
|
||||
onDecline = { CallManager.declineIncomingCall() },
|
||||
onEnd = { CallManager.endCall() },
|
||||
onToggleMute = { CallManager.toggleMute() },
|
||||
onToggleSpeaker = { CallManager.toggleSpeaker() }
|
||||
onToggleSpeaker = { CallManager.toggleSpeaker() },
|
||||
onMinimize = {
|
||||
if (callUiState.phase != CallPhase.INCOMING) {
|
||||
isCallOverlayExpanded = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Оптимизация производительности и стабильности
|
||||
- В release отключена frame-диагностика E2EE (детальные frame-логи оставлены только в debug)
|
||||
- Оптимизирован чат-лист: убрано дублирование collectAsState и вынесены route-компоненты
|
||||
- Ускорены выборки по вложениям: добавлен denormalized attachment type и индекс в БД
|
||||
- Добавлен macrobenchmark-модуль с замерами startup, search и chat list scroll
|
||||
- Исправлено поведение UI в звонке: клавиатура автоматически закрывается при открытии call overlay
|
||||
Звонки и навигация
|
||||
- Звонок можно свернуть и продолжить в фоне приложения с закрепленной верхней плашкой в чат-листе (Telegram-style)
|
||||
- Обновлен экран звонка: кнопка сворачивания в стиле Telegram, улучшено поведение overlay
|
||||
- Исправлено скрытие клавиатуры при открытии звонка
|
||||
|
||||
Поиск в диалоге
|
||||
- В kebab-меню чата добавлен пункт Search
|
||||
- Реализован поиск сообщений внутри текущего диалога (локально по индексу message_search_index)
|
||||
- Добавлена навигация по результатам поиска (предыдущий/следующий) с автопереходом к сообщению
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -695,6 +695,24 @@ interface MessageSearchIndexDao {
|
||||
offset: Int = 0
|
||||
): List<MessageSearchIndexEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM message_search_index
|
||||
WHERE account = :account
|
||||
AND dialog_key = :dialogKey
|
||||
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun searchInDialog(
|
||||
account: String,
|
||||
dialogKey: String,
|
||||
queryNormalized: String,
|
||||
limit: Int,
|
||||
offset: Int = 0
|
||||
): List<MessageSearchIndexEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT m.* FROM messages m
|
||||
|
||||
@@ -48,10 +48,12 @@ import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -125,6 +127,27 @@ private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
|
||||
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
||||
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
||||
|
||||
private enum class ChatHeaderMode {
|
||||
NORMAL,
|
||||
SEARCH,
|
||||
SELECTION
|
||||
}
|
||||
|
||||
private fun buildDialogKeyForSearch(account: String, opponent: String): String {
|
||||
val normalizedAccount = account.trim()
|
||||
val normalizedOpponent = opponent.trim()
|
||||
val normalizedLower = normalizedOpponent.lowercase(Locale.ROOT)
|
||||
val isGroup =
|
||||
normalizedLower.startsWith("#group:") || normalizedLower.startsWith("group:")
|
||||
if (isGroup) return normalizedOpponent
|
||||
if (normalizedAccount == normalizedOpponent) return normalizedAccount
|
||||
return if (normalizedAccount < normalizedOpponent) {
|
||||
"$normalizedAccount:$normalizedOpponent"
|
||||
} else {
|
||||
"$normalizedOpponent:$normalizedAccount"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
|
||||
val firstCalendar =
|
||||
java.util.Calendar.getInstance().apply {
|
||||
@@ -308,6 +331,7 @@ fun ChatDetailScreen(
|
||||
val focusManager = LocalFocusManager.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val database = RosettaDatabase.getDatabase(context)
|
||||
val searchIndexDao = remember(database) { database.messageSearchIndexDao() }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
// 🔇 Mute state — read from PreferencesManager
|
||||
@@ -772,6 +796,18 @@ fun ChatDetailScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||
var isInChatSearchMode by rememberSaveable(user.publicKey) { mutableStateOf(false) }
|
||||
var inChatSearchQuery by rememberSaveable(user.publicKey) { mutableStateOf("") }
|
||||
var inChatSearchResultIds by remember(user.publicKey) { mutableStateOf<List<String>>(emptyList()) }
|
||||
var inChatSearchResultIndex by rememberSaveable(user.publicKey) { mutableIntStateOf(-1) }
|
||||
val searchFieldFocusRequester = remember(user.publicKey) { FocusRequester() }
|
||||
val searchDialogKey =
|
||||
remember(currentUserPublicKey, user.publicKey) {
|
||||
buildDialogKeyForSearch(
|
||||
account = currentUserPublicKey,
|
||||
opponent = user.publicKey
|
||||
)
|
||||
}
|
||||
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||
val isBlocked by
|
||||
database.blacklistDao()
|
||||
@@ -1217,6 +1253,90 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val closeInChatSearch: () -> Unit = {
|
||||
isInChatSearchMode = false
|
||||
inChatSearchQuery = ""
|
||||
inChatSearchResultIds = emptyList()
|
||||
inChatSearchResultIndex = -1
|
||||
hideInputOverlays()
|
||||
}
|
||||
val jumpToSearchResult: (Int) -> Unit = jump@{ targetIndex ->
|
||||
val ids = inChatSearchResultIds
|
||||
if (ids.isEmpty()) return@jump
|
||||
val normalizedIndex =
|
||||
if (targetIndex >= 0) {
|
||||
targetIndex % ids.size
|
||||
} else {
|
||||
((targetIndex % ids.size) + ids.size) % ids.size
|
||||
}
|
||||
inChatSearchResultIndex = normalizedIndex
|
||||
scrollToMessage(ids[normalizedIndex])
|
||||
}
|
||||
val searchResultCounterText =
|
||||
remember(inChatSearchResultIds, inChatSearchResultIndex) {
|
||||
if (inChatSearchResultIds.isEmpty() || inChatSearchResultIndex < 0) {
|
||||
"0/0"
|
||||
} else {
|
||||
"${inChatSearchResultIndex + 1}/${inChatSearchResultIds.size}"
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isInChatSearchMode) {
|
||||
if (isInChatSearchMode) {
|
||||
delay(80)
|
||||
searchFieldFocusRequester.requestFocus()
|
||||
} else {
|
||||
inChatSearchQuery = ""
|
||||
inChatSearchResultIds = emptyList()
|
||||
inChatSearchResultIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isSelectionMode) {
|
||||
if (isSelectionMode && isInChatSearchMode) {
|
||||
closeInChatSearch()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
isInChatSearchMode,
|
||||
inChatSearchQuery,
|
||||
currentUserPublicKey,
|
||||
searchDialogKey
|
||||
) {
|
||||
if (!isInChatSearchMode) return@LaunchedEffect
|
||||
|
||||
val account = currentUserPublicKey.trim()
|
||||
val normalizedQuery = inChatSearchQuery.trim().lowercase(Locale.ROOT)
|
||||
if (account.isBlank() || normalizedQuery.length < 2) {
|
||||
inChatSearchResultIds = emptyList()
|
||||
inChatSearchResultIndex = -1
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
delay(250)
|
||||
val resultIds =
|
||||
withContext(Dispatchers.IO) {
|
||||
searchIndexDao
|
||||
.searchInDialog(
|
||||
account = account,
|
||||
dialogKey = searchDialogKey,
|
||||
queryNormalized = normalizedQuery,
|
||||
limit = 200,
|
||||
offset = 0
|
||||
)
|
||||
.map { it.messageId }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
inChatSearchResultIds = resultIds
|
||||
if (resultIds.isEmpty()) {
|
||||
inChatSearchResultIndex = -1
|
||||
} else {
|
||||
inChatSearchResultIndex = 0
|
||||
scrollToMessage(resultIds.first())
|
||||
}
|
||||
}
|
||||
|
||||
// Динамический subtitle: typing > online > offline
|
||||
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
|
||||
@@ -1241,7 +1361,13 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// 🔥 Обработка системной кнопки назад
|
||||
BackHandler { hideKeyboardAndBack() }
|
||||
BackHandler {
|
||||
if (isInChatSearchMode) {
|
||||
closeInChatSearch()
|
||||
} else {
|
||||
hideKeyboardAndBack()
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Lifecycle-aware отслеживание активности экрана
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -1433,12 +1559,18 @@ fun ChatDetailScreen(
|
||||
) {
|
||||
// Контент хедера с Crossfade для плавной смены - ускоренная
|
||||
// анимация
|
||||
val headerMode =
|
||||
when {
|
||||
isSelectionMode -> ChatHeaderMode.SELECTION
|
||||
isInChatSearchMode -> ChatHeaderMode.SEARCH
|
||||
else -> ChatHeaderMode.NORMAL
|
||||
}
|
||||
Crossfade(
|
||||
targetState = isSelectionMode,
|
||||
targetState = headerMode,
|
||||
animationSpec = tween(150),
|
||||
label = "headerContent"
|
||||
) { selectionMode ->
|
||||
if (selectionMode) {
|
||||
) { currentHeaderMode ->
|
||||
if (currentHeaderMode == ChatHeaderMode.SELECTION) {
|
||||
// SELECTION MODE CONTENT
|
||||
Row(
|
||||
modifier =
|
||||
@@ -1601,6 +1733,129 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (currentHeaderMode == ChatHeaderMode.SEARCH) {
|
||||
// SEARCH MODE CONTENT
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { closeInChatSearch() },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronLeft,
|
||||
contentDescription = "Close search",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = inChatSearchQuery,
|
||||
onValueChange = { value ->
|
||||
inChatSearchQuery = value
|
||||
},
|
||||
singleLine = true,
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
.focusRequester(searchFieldFocusRequester),
|
||||
textStyle =
|
||||
LocalTextStyle.current.copy(
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Search in chat",
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.75f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (inChatSearchQuery.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
inChatSearchQuery = ""
|
||||
inChatSearchResultIds = emptyList()
|
||||
inChatSearchResultIndex = -1
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear search",
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
cursorColor = Color.White
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = searchResultCounterText,
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
jumpToSearchResult(
|
||||
inChatSearchResultIndex - 1
|
||||
)
|
||||
},
|
||||
enabled = inChatSearchResultIds.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronUp,
|
||||
contentDescription = "Previous result",
|
||||
tint =
|
||||
if (inChatSearchResultIds.isNotEmpty()) Color.White
|
||||
else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
jumpToSearchResult(
|
||||
inChatSearchResultIndex + 1
|
||||
)
|
||||
},
|
||||
enabled = inChatSearchResultIds.isNotEmpty()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronDown,
|
||||
contentDescription = "Next result",
|
||||
tint =
|
||||
if (inChatSearchResultIds.isNotEmpty()) Color.White
|
||||
else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// NORMAL HEADER CONTENT
|
||||
Row(
|
||||
@@ -1943,6 +2198,14 @@ fun ChatDetailScreen(
|
||||
isSystemAccount,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
onSearchInChatClick = {
|
||||
showMenu = false
|
||||
hideInputOverlays()
|
||||
isInChatSearchMode = true
|
||||
inChatSearchQuery = ""
|
||||
inChatSearchResultIds = emptyList()
|
||||
inChatSearchResultIndex = -1
|
||||
},
|
||||
onGroupInfoClick = {
|
||||
showMenu =
|
||||
false
|
||||
|
||||
@@ -64,11 +64,14 @@ import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallUiState
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
||||
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
@@ -268,6 +271,9 @@ fun ChatsListScreen(
|
||||
onTogglePin: (String) -> Unit = {},
|
||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
callUiState: CallUiState = CallUiState(),
|
||||
isCallOverlayExpanded: Boolean = true,
|
||||
onOpenCallOverlay: () -> Unit = {},
|
||||
onAddAccount: () -> Unit = {},
|
||||
onSwitchAccount: (String) -> Unit = {},
|
||||
onDeleteAccountFromSidebar: (String) -> Unit = {}
|
||||
@@ -2095,6 +2101,13 @@ fun ChatsListScreen(
|
||||
Color(0xFF1A1A1A)
|
||||
else Color(0xFFF2F2F7)
|
||||
}
|
||||
val showStickyCallBanner =
|
||||
remember(callUiState, isCallOverlayExpanded) {
|
||||
callUiState.isVisible &&
|
||||
!isCallOverlayExpanded &&
|
||||
callUiState.phase != CallPhase.INCOMING
|
||||
}
|
||||
val callBannerHeight = 40.dp
|
||||
// 🔥 Берем dialogs из chatsState для
|
||||
// консистентности
|
||||
// 📌 Порядок по времени готовится в ViewModel.
|
||||
@@ -2286,10 +2299,21 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(listBackgroundColor)
|
||||
) {
|
||||
LazyColumn(
|
||||
state = chatListState,
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.padding(
|
||||
top =
|
||||
if (showStickyCallBanner)
|
||||
callBannerHeight
|
||||
else 0.dp
|
||||
)
|
||||
.then(
|
||||
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
||||
else Modifier
|
||||
@@ -2627,6 +2651,16 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showStickyCallBanner) {
|
||||
CallTopBanner(
|
||||
state = callUiState,
|
||||
isSticky = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onOpenCall = onOpenCallOverlay
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Close Requests AnimatedContent
|
||||
} // Close calls/main switch
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.QrCode2
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -45,6 +46,8 @@ import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallUiState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.ChevronDown
|
||||
|
||||
// ── Telegram-style dark gradient colors ──────────────────────────
|
||||
|
||||
@@ -66,11 +69,13 @@ fun CallOverlay(
|
||||
state: CallUiState,
|
||||
isDarkTheme: Boolean,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
isExpanded: Boolean = true,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
onEnd: () -> Unit,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onMinimize: (() -> Unit)? = null
|
||||
) {
|
||||
val view = LocalView.current
|
||||
LaunchedEffect(state.isVisible) {
|
||||
@@ -85,7 +90,7 @@ fun CallOverlay(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
visible = state.isVisible && isExpanded,
|
||||
enter = fadeIn(tween(300)),
|
||||
exit = fadeOut(tween(200))
|
||||
) {
|
||||
@@ -96,17 +101,43 @@ fun CallOverlay(
|
||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
||||
)
|
||||
) {
|
||||
// ── Top-right QR icon ──
|
||||
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
|
||||
Box(
|
||||
// ── Top controls: minimize (left) + key cast QR (right) ──
|
||||
val canMinimize = onMinimize != null && state.phase != CallPhase.INCOMING && state.phase != CallPhase.IDLE
|
||||
val showKeyCast =
|
||||
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) &&
|
||||
state.keyCast.isNotBlank()
|
||||
|
||||
if (canMinimize || showKeyCast) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (canMinimize) {
|
||||
IconButton(
|
||||
onClick = { onMinimize?.invoke() },
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronDown,
|
||||
contentDescription = "Minimize call",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
if (showKeyCast) {
|
||||
EncryptionKeyButton(keyHex = state.keyCast)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.rosetta.messenger.ui.chats.calls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallUiState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
|
||||
private val TelegramCallGreenStart = Color(0xFF3FD17F)
|
||||
private val TelegramCallGreenEnd = Color(0xFF27C4AE)
|
||||
|
||||
@Composable
|
||||
fun CallTopBanner(
|
||||
state: CallUiState,
|
||||
onOpenCall: () -> Unit,
|
||||
isSticky: Boolean = false,
|
||||
isDarkTheme: Boolean = true,
|
||||
avatarRepository: AvatarRepository? = null
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible && state.phase != CallPhase.INCOMING,
|
||||
enter = slideInVertically(initialOffsetY = { -it / 2 }, animationSpec = tween(220)) +
|
||||
fadeIn(animationSpec = tween(220)),
|
||||
exit = slideOutVertically(targetOffsetY = { -it / 2 }, animationSpec = tween(180)) +
|
||||
fadeOut(animationSpec = tween(160))
|
||||
) {
|
||||
val bannerModifier =
|
||||
if (isSticky) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
.height(42.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = bannerModifier
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(TelegramCallGreenStart, TelegramCallGreenEnd)
|
||||
)
|
||||
)
|
||||
.clickable { onOpenCall() }
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.offset(x = if (isSticky) (-4).dp else 0.dp)) {
|
||||
AvatarImage(
|
||||
publicKey = state.peerPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 24.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showOnlineIndicator = false,
|
||||
displayName = state.displayName
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = buildBannerText(state),
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.16f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = "Voice call",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(17.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBannerText(state: CallUiState): String {
|
||||
return state.displayName
|
||||
}
|
||||
@@ -3245,6 +3245,7 @@ fun KebabMenu(
|
||||
isGroupChat: Boolean = false,
|
||||
isSystemAccount: Boolean = false,
|
||||
isBlocked: Boolean,
|
||||
onSearchInChatClick: () -> Unit = {},
|
||||
onGroupInfoClick: () -> Unit = {},
|
||||
onSearchMembersClick: () -> Unit = {},
|
||||
onLeaveGroupClick: () -> Unit = {},
|
||||
@@ -3276,7 +3277,16 @@ fun KebabMenu(
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
ContextMenuItemWithVector(
|
||||
icon = TablerIcons.Search,
|
||||
text = "Search",
|
||||
onClick = onSearchInChatClick,
|
||||
tintColor = iconColor,
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
if (isGroupChat) {
|
||||
Divider(color = dividerColor)
|
||||
ContextMenuItemWithVector(
|
||||
icon = TablerIcons.Search,
|
||||
text = "Search Members",
|
||||
|
||||
Reference in New Issue
Block a user