diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d8c905a..9f660bb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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, чаты и производительность diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42b3dae..47bfa5e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 3395341..8176960 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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(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() }, @@ -1578,14 +1601,20 @@ fun MainScreen( } CallOverlay( - state = callUiState, - isDarkTheme = isDarkTheme, - avatarRepository = avatarRepository, - onAccept = { acceptCallWithPermission() }, - onDecline = { CallManager.declineIncomingCall() }, - onEnd = { CallManager.endCall() }, - onToggleMute = { CallManager.toggleMute() }, - onToggleSpeaker = { CallManager.toggleSpeaker() } + 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() }, + onMinimize = { + if (callUiState.phase != CallPhase.INCOMING) { + isCallOverlayExpanded = false + } + } ) } } diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 867efc5..24fddd5 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 13fdf0c..43edcb1 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -695,6 +695,24 @@ interface MessageSearchIndexDao { offset: Int = 0 ): List + @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 + @Query( """ SELECT m.* FROM messages m diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 7c5511d..8c097b3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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>(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index f6e89ac..641a721 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt index f63e366..9abc292 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -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 ) { - EncryptionKeyButton(keyHex = state.keyCast) + 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)) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallTopBanner.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallTopBanner.kt new file mode 100644 index 0000000..672758c --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallTopBanner.kt @@ -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 +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 513c993..a00be03 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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",