Compare commits
5 Commits
0aa34e75c9
...
8c2e30b4d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2e30b4d8 | |||
| 9568d83a08 | |||
| 179f65872d | |||
| 160ba4e2e7 | |||
| aa096e2e87 |
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.1.7"
|
||||
val rosettaVersionCode = 19 // Increment on each release
|
||||
val rosettaVersionName = "1.1.8"
|
||||
val rosettaVersionCode = 20 // Increment on each release
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
|
||||
@@ -17,14 +17,22 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Уведомления
|
||||
- Исправлена регистрация push-токена после переподключений
|
||||
- Добавлен fallback для нестандартных payload, чтобы push-уведомления не терялись
|
||||
- Улучшена отправка push-токена сразу после получения FCM токена
|
||||
Полноэкранное фото из медиапикера
|
||||
- Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс
|
||||
- Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи
|
||||
- Добавлено закрытие свайпом вверх/вниз с плавной анимацией
|
||||
- Убраны рывки, мигание и лишнее уменьшение фото при перелистывании
|
||||
|
||||
Интерфейс
|
||||
- Улучшено поведение сворачивания приложения в стиле Telegram
|
||||
- Стабилизировано отображение нижней системной панели навигации
|
||||
Редактирование и отправка
|
||||
- Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера
|
||||
- Улучшена пересылка фото через optimistic UI: сообщение отображается сразу
|
||||
- Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options
|
||||
- Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply
|
||||
|
||||
Группы
|
||||
- В списках участников групп отображается только статус online/offline
|
||||
- На экране создания группы у текущего пользователя статус отображается как online
|
||||
- Поиск участников по username сохранен
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -137,6 +138,28 @@ private data class IncomingRunAvatarUiState(
|
||||
val overlays: List<IncomingRunAvatarOverlay>
|
||||
)
|
||||
|
||||
private fun extractCopyableMessageText(message: ChatMessage): String {
|
||||
val directText = message.text.trim()
|
||||
if (directText.isNotEmpty()) {
|
||||
return directText
|
||||
}
|
||||
|
||||
val forwardedText =
|
||||
message.forwardedMessages
|
||||
.mapNotNull { forwarded -> forwarded.text.trim().takeIf { it.isNotEmpty() } }
|
||||
.joinToString("\n\n")
|
||||
if (forwardedText.isNotEmpty()) {
|
||||
return forwardedText
|
||||
}
|
||||
|
||||
val replyText = message.replyData?.text?.trim().orEmpty()
|
||||
if (replyText.isNotEmpty()) {
|
||||
return replyText
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||
@@ -162,6 +185,9 @@ fun ChatDetailScreen(
|
||||
) {
|
||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||
val context = LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -290,6 +316,11 @@ fun ChatDetailScreen(
|
||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
|
||||
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
|
||||
var simplePickerPreviewCaption by remember { mutableStateOf("") }
|
||||
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
||||
if (!view.isInEditMode) {
|
||||
@@ -364,7 +395,8 @@ fun ChatDetailScreen(
|
||||
showEmojiPicker,
|
||||
pendingCameraPhotoUri,
|
||||
pendingGalleryImages,
|
||||
showInAppCamera
|
||||
showInAppCamera,
|
||||
simplePickerPreviewUri
|
||||
) {
|
||||
derivedStateOf {
|
||||
showImageViewer ||
|
||||
@@ -372,7 +404,8 @@ fun ChatDetailScreen(
|
||||
showEmojiPicker ||
|
||||
pendingCameraPhotoUri != null ||
|
||||
pendingGalleryImages.isNotEmpty() ||
|
||||
showInAppCamera
|
||||
showInAppCamera ||
|
||||
simplePickerPreviewUri != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +413,13 @@ fun ChatDetailScreen(
|
||||
onImageViewerChanged(shouldLockParentSwipeBack)
|
||||
}
|
||||
|
||||
LaunchedEffect(simplePickerPreviewUri) {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { onImageViewerChanged(false) }
|
||||
}
|
||||
@@ -1189,30 +1229,41 @@ fun ChatDetailScreen(
|
||||
{ it.id }
|
||||
)
|
||||
)
|
||||
.joinToString(
|
||||
"\n\n"
|
||||
) {
|
||||
.mapNotNull {
|
||||
msg
|
||||
->
|
||||
val time =
|
||||
SimpleDateFormat(
|
||||
"HH:mm",
|
||||
Locale.getDefault()
|
||||
)
|
||||
.format(
|
||||
msg.timestamp
|
||||
)
|
||||
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}"
|
||||
val messageText =
|
||||
extractCopyableMessageText(
|
||||
msg
|
||||
)
|
||||
if (messageText.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val time =
|
||||
SimpleDateFormat(
|
||||
"HH:mm",
|
||||
Locale.getDefault()
|
||||
)
|
||||
.format(
|
||||
msg.timestamp
|
||||
)
|
||||
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
|
||||
}
|
||||
}
|
||||
clipboardManager
|
||||
.setText(
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.AnnotatedString(
|
||||
textToCopy
|
||||
)
|
||||
)
|
||||
.joinToString(
|
||||
"\n\n"
|
||||
)
|
||||
if (textToCopy.isNotBlank()) {
|
||||
clipboardManager
|
||||
.setText(
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.AnnotatedString(
|
||||
textToCopy
|
||||
)
|
||||
)
|
||||
}
|
||||
selectedMessages =
|
||||
emptySet()
|
||||
}
|
||||
@@ -1801,7 +1852,10 @@ fun ChatDetailScreen(
|
||||
bottom =
|
||||
16.dp
|
||||
)
|
||||
.navigationBarsPadding()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
.graphicsLayer {
|
||||
scaleX =
|
||||
buttonScale
|
||||
@@ -1975,14 +2029,7 @@ fun ChatDetailScreen(
|
||||
it.type !=
|
||||
AttachmentType
|
||||
.MESSAGES
|
||||
}
|
||||
.map {
|
||||
attachment ->
|
||||
attachment.copy(
|
||||
localUri =
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -2014,14 +2061,7 @@ fun ChatDetailScreen(
|
||||
it.type !=
|
||||
AttachmentType
|
||||
.MESSAGES
|
||||
}
|
||||
.map {
|
||||
attachment ->
|
||||
attachment.copy(
|
||||
localUri =
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -2130,6 +2170,47 @@ fun ChatDetailScreen(
|
||||
viewModel
|
||||
.clearReplyMessages()
|
||||
},
|
||||
onShowForwardOptions = { panelMessages ->
|
||||
if (panelMessages.isEmpty()) {
|
||||
return@MessageInputBar
|
||||
}
|
||||
val forwardMessages =
|
||||
panelMessages.map { msg ->
|
||||
ForwardManager.ForwardMessage(
|
||||
messageId =
|
||||
msg.messageId,
|
||||
text = msg.text,
|
||||
timestamp =
|
||||
msg.timestamp,
|
||||
isOutgoing =
|
||||
msg.isOutgoing,
|
||||
senderPublicKey =
|
||||
msg.publicKey.ifEmpty {
|
||||
if (msg.isOutgoing) currentUserPublicKey
|
||||
else user.publicKey
|
||||
},
|
||||
originalChatPublicKey =
|
||||
user.publicKey,
|
||||
senderName =
|
||||
msg.senderName.ifEmpty {
|
||||
if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
|
||||
else user.title.ifEmpty { user.username.ifEmpty { "User" } }
|
||||
},
|
||||
attachments =
|
||||
msg.attachments
|
||||
.filter {
|
||||
it.type != AttachmentType.MESSAGES
|
||||
}
|
||||
.map { it.copy(localUri = "") }
|
||||
)
|
||||
}
|
||||
|
||||
ForwardManager.setForwardMessages(
|
||||
forwardMessages,
|
||||
showPicker = false
|
||||
)
|
||||
showForwardPicker = true
|
||||
},
|
||||
chatTitle = chatTitle,
|
||||
isBlocked = isBlocked,
|
||||
showEmojiPicker =
|
||||
@@ -2168,7 +2249,9 @@ fun ChatDetailScreen(
|
||||
inputFocusTrigger =
|
||||
inputFocusTrigger,
|
||||
suppressKeyboard =
|
||||
showInAppCamera
|
||||
showInAppCamera,
|
||||
hasNativeNavigationBar =
|
||||
hasNativeNavigationBar
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2513,6 +2596,9 @@ fun ChatDetailScreen(
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
onLongClick = {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
return@MessageBubble
|
||||
}
|
||||
// 📳 Haptic feedback при долгом нажатии
|
||||
// Не разрешаем выделять avatar-сообщения
|
||||
val hasAvatar =
|
||||
@@ -2553,6 +2639,9 @@ fun ChatDetailScreen(
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
return@MessageBubble
|
||||
}
|
||||
if (shouldIgnoreTapAfterLongPress(
|
||||
selectionKey
|
||||
)
|
||||
@@ -2734,7 +2823,7 @@ fun ChatDetailScreen(
|
||||
// 💬 Context menu anchored to this bubble
|
||||
if (showContextMenu && contextMenuMessage?.id == message.id) {
|
||||
val msg = contextMenuMessage!!
|
||||
MessageContextMenu(
|
||||
MessageContextMenu(
|
||||
expanded = true,
|
||||
onDismiss = {
|
||||
showContextMenu = false
|
||||
@@ -2743,7 +2832,7 @@ fun ChatDetailScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
isPinned = contextMenuIsPinned,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
hasText = msg.text.isNotBlank(),
|
||||
hasText = extractCopyableMessageText(msg).isNotBlank(),
|
||||
isSystemAccount = isSystemAccount,
|
||||
onReply = {
|
||||
viewModel.setReplyMessages(listOf(msg))
|
||||
@@ -2752,7 +2841,7 @@ fun ChatDetailScreen(
|
||||
},
|
||||
onCopy = {
|
||||
clipboardManager.setText(
|
||||
androidx.compose.ui.text.AnnotatedString(msg.text)
|
||||
androidx.compose.ui.text.AnnotatedString(extractCopyableMessageText(msg))
|
||||
)
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
@@ -3005,7 +3094,24 @@ fun ChatDetailScreen(
|
||||
onAvatarClick = {
|
||||
viewModel.sendAvatarMessage()
|
||||
},
|
||||
recipientName = user.title
|
||||
recipientName = user.title,
|
||||
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
|
||||
hideInputOverlays()
|
||||
showMediaPicker = false
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
simplePickerPreviewSourceThumb = sourceThumb
|
||||
simplePickerPreviewCaption = ""
|
||||
simplePickerPreviewUri = uri
|
||||
val normalizedGallery =
|
||||
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
|
||||
simplePickerPreviewGalleryUris = normalizedGallery
|
||||
simplePickerPreviewInitialIndex =
|
||||
initialIndex.coerceIn(
|
||||
0,
|
||||
(normalizedGallery.size - 1).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MediaPickerBottomSheet(
|
||||
@@ -3049,7 +3155,24 @@ fun ChatDetailScreen(
|
||||
onAvatarClick = {
|
||||
viewModel.sendAvatarMessage()
|
||||
},
|
||||
recipientName = user.title
|
||||
recipientName = user.title,
|
||||
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
|
||||
hideInputOverlays()
|
||||
showMediaPicker = false
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
simplePickerPreviewSourceThumb = sourceThumb
|
||||
simplePickerPreviewCaption = ""
|
||||
simplePickerPreviewUri = uri
|
||||
val normalizedGallery =
|
||||
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
|
||||
simplePickerPreviewGalleryUris = normalizedGallery
|
||||
simplePickerPreviewInitialIndex =
|
||||
initialIndex.coerceIn(
|
||||
0,
|
||||
(normalizedGallery.size - 1).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
} // Закрытие Box wrapper для Scaffold content
|
||||
@@ -3282,12 +3405,40 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
val forwardMessages = ForwardManager.consumeForwardMessages()
|
||||
ForwardManager.clear()
|
||||
if (forwardMessages.isEmpty()) {
|
||||
ForwardManager.clear()
|
||||
return@ForwardChatPickerBottomSheet
|
||||
}
|
||||
|
||||
// Реальная отправка forward во все выбранные чаты.
|
||||
// Desktop parity: если выбран один чат, не отправляем сразу.
|
||||
// Открываем чат с forward-панелью, чтобы пользователь мог
|
||||
// добавить подпись и отправить вручную.
|
||||
if (selectedDialogs.size == 1) {
|
||||
val targetDialog = selectedDialogs.first()
|
||||
ForwardManager.setForwardMessages(
|
||||
forwardMessages,
|
||||
showPicker = false
|
||||
)
|
||||
ForwardManager.selectChat(targetDialog.opponentKey)
|
||||
|
||||
if (targetDialog.opponentKey != user.publicKey) {
|
||||
val searchUser =
|
||||
SearchUser(
|
||||
title = targetDialog.opponentTitle,
|
||||
username =
|
||||
targetDialog.opponentUsername,
|
||||
publicKey = targetDialog.opponentKey,
|
||||
verified = targetDialog.verified,
|
||||
online = targetDialog.isOnline
|
||||
)
|
||||
onNavigateToChat(searchUser)
|
||||
}
|
||||
return@ForwardChatPickerBottomSheet
|
||||
}
|
||||
|
||||
ForwardManager.clear()
|
||||
|
||||
// Мультивыбор оставляем прямой отправкой как раньше.
|
||||
selectedDialogs.forEach { dialog ->
|
||||
viewModel.sendForwardDirectly(
|
||||
dialog.opponentKey,
|
||||
@@ -3314,6 +3465,38 @@ fun ChatDetailScreen(
|
||||
|
||||
} // Закрытие Scaffold content lambda
|
||||
|
||||
simplePickerPreviewUri?.let { previewUri ->
|
||||
SimpleFullscreenPhotoOverlay(
|
||||
imageUri = previewUri,
|
||||
sourceThumbnail = simplePickerPreviewSourceThumb,
|
||||
galleryImageUris = simplePickerPreviewGalleryUris,
|
||||
initialGalleryIndex = simplePickerPreviewInitialIndex,
|
||||
modifier = Modifier.fillMaxSize().zIndex(100f),
|
||||
showCaptionInput = true,
|
||||
caption = simplePickerPreviewCaption,
|
||||
onCaptionChange = { simplePickerPreviewCaption = it },
|
||||
isDarkTheme = isDarkTheme,
|
||||
onSend = { editedUri, caption ->
|
||||
viewModel.sendImageFromUri(editedUri, caption)
|
||||
showMediaPicker = false
|
||||
simplePickerPreviewUri = null
|
||||
simplePickerPreviewSourceThumb = null
|
||||
simplePickerPreviewCaption = ""
|
||||
simplePickerPreviewGalleryUris = emptyList()
|
||||
simplePickerPreviewInitialIndex = 0
|
||||
inputFocusTrigger++
|
||||
},
|
||||
onDismiss = {
|
||||
simplePickerPreviewUri = null
|
||||
simplePickerPreviewSourceThumb = null
|
||||
simplePickerPreviewCaption = ""
|
||||
simplePickerPreviewGalleryUris = emptyList()
|
||||
simplePickerPreviewInitialIndex = 0
|
||||
inputFocusTrigger++
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
|
||||
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||
ImageViewerScreen(
|
||||
|
||||
@@ -1582,10 +1582,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attBlob = attJson.optString("blob", "")
|
||||
val attWidth = attJson.optInt("width", 0)
|
||||
val attHeight = attJson.optInt("height", 0)
|
||||
val attLocalUri = attJson.optString("localUri", "")
|
||||
if (attId.isNotEmpty()) {
|
||||
fwdAttachments.add(MessageAttachment(
|
||||
id = attId, type = attType, preview = attPreview,
|
||||
blob = attBlob, width = attWidth, height = attHeight
|
||||
blob = attBlob, width = attWidth, height = attHeight,
|
||||
localUri = attLocalUri
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1662,6 +1664,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attBlob = attJson.optString("blob", "")
|
||||
val attWidth = attJson.optInt("width", 0)
|
||||
val attHeight = attJson.optInt("height", 0)
|
||||
val attLocalUri = attJson.optString("localUri", "")
|
||||
|
||||
if (attId.isNotEmpty()) {
|
||||
replyAttachmentsFromJson.add(
|
||||
@@ -1671,7 +1674,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
preview = attPreview,
|
||||
blob = attBlob,
|
||||
width = attWidth,
|
||||
height = attHeight
|
||||
height = attHeight,
|
||||
localUri = attLocalUri
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2239,6 +2243,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val sender = myPublicKey
|
||||
val privateKey = myPrivateKey
|
||||
val replyMsgs = _replyMessages.value
|
||||
val replyMsgsToSend = replyMsgs.toList()
|
||||
val isForward = _isForwardMode.value
|
||||
|
||||
// Разрешаем отправку пустого текста если есть reply/forward
|
||||
@@ -2267,10 +2272,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
|
||||
// Работает и для reply, и для forward
|
||||
// Используется для обычного reply (не forward).
|
||||
val replyData: ReplyData? =
|
||||
if (replyMsgs.isNotEmpty()) {
|
||||
val firstReply = replyMsgs.first()
|
||||
if (replyMsgsToSend.isNotEmpty()) {
|
||||
val firstReply = replyMsgsToSend.first()
|
||||
// 🖼️ Получаем attachments из текущих сообщений для превью
|
||||
// Fallback на firstReply.attachments для forward из другого чата
|
||||
val replyAttachments =
|
||||
@@ -2298,8 +2303,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
} else null
|
||||
|
||||
// Сохраняем reply для отправки ПЕРЕД очисткой
|
||||
val replyMsgsToSend = replyMsgs.toList()
|
||||
// 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble,
|
||||
// а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение.
|
||||
val optimisticForwardedMessages: List<ReplyData> =
|
||||
if (isForward && replyMsgsToSend.isNotEmpty()) {
|
||||
replyMsgsToSend.map { msg ->
|
||||
val senderDisplayName =
|
||||
if (msg.isOutgoing) "You"
|
||||
else msg.senderName.ifEmpty {
|
||||
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
||||
}
|
||||
val resolvedAttachments =
|
||||
_messages.value.find { it.id == msg.messageId }?.attachments
|
||||
?: msg.attachments.filter { it.type != AttachmentType.MESSAGES }
|
||||
ReplyData(
|
||||
messageId = msg.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = msg.text,
|
||||
isFromMe = msg.isOutgoing,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
attachments = resolvedAttachments,
|
||||
senderPublicKey = msg.publicKey.ifEmpty {
|
||||
if (msg.isOutgoing) myPublicKey ?: "" else opponentKey ?: ""
|
||||
},
|
||||
recipientPrivateKey = myPrivateKey ?: ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// Сохраняем режим forward для отправки ПЕРЕД очисткой
|
||||
val isForwardToSend = isForward
|
||||
|
||||
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
|
||||
@@ -2310,7 +2345,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
replyData = replyData // Данные для reply bubble
|
||||
replyData = if (isForwardToSend) null else replyData,
|
||||
forwardedMessages = optimisticForwardedMessages
|
||||
)
|
||||
|
||||
// <20> Безопасное добавление с проверкой дубликатов
|
||||
@@ -2566,12 +2602,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val privateKey = myPrivateKey ?: return
|
||||
if (forwardMessages.isEmpty()) return
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val isCurrentDialogTarget = recipientPublicKey == opponentKey
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val isSavedMessages = (sender == recipientPublicKey)
|
||||
val db = RosettaDatabase.getDatabase(context)
|
||||
val dialogDao = db.dialogDao()
|
||||
|
||||
suspend fun refreshTargetDialog() {
|
||||
if (isSavedMessages) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(sender)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Шифрование (пустой текст для forward)
|
||||
val encryptionContext =
|
||||
@@ -2584,11 +2632,133 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
var replyBlobForDatabase = ""
|
||||
fun buildForwardReplyJson(
|
||||
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
|
||||
includeLocalUri: Boolean
|
||||
): JSONArray {
|
||||
val replyJsonArray = JSONArray()
|
||||
forwardMessages.forEach { fm ->
|
||||
val attachmentsArray = JSONArray()
|
||||
fm.attachments.forEach { att ->
|
||||
val fwdInfo = forwardedIdMap[att.id]
|
||||
val attId = fwdInfo?.first ?: att.id
|
||||
val attPreview = fwdInfo?.second ?: att.preview
|
||||
|
||||
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", attId)
|
||||
put("type", att.type.value)
|
||||
put("preview", attPreview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||
if (includeLocalUri && att.localUri.isNotEmpty()) {
|
||||
put("localUri", att.localUri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
replyJsonArray.put(
|
||||
JSONObject().apply {
|
||||
put("message_id", fm.messageId)
|
||||
put("publicKey", fm.senderPublicKey)
|
||||
put("message", fm.text)
|
||||
put("timestamp", fm.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", fm.senderName)
|
||||
}
|
||||
)
|
||||
}
|
||||
return replyJsonArray
|
||||
}
|
||||
|
||||
// 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге
|
||||
if (isCurrentDialogTarget) {
|
||||
val optimisticForwardedMessages =
|
||||
forwardMessages.map { fm ->
|
||||
val senderDisplayName =
|
||||
fm.senderName.ifEmpty {
|
||||
if (fm.senderPublicKey == sender) "You" else "User"
|
||||
}
|
||||
ReplyData(
|
||||
messageId = fm.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = fm.text,
|
||||
isFromMe = fm.senderPublicKey == sender,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES },
|
||||
senderPublicKey = fm.senderPublicKey,
|
||||
recipientPrivateKey = privateKey
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
addMessageSafely(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
forwardedMessages = optimisticForwardedMessages
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу
|
||||
val optimisticReplyBlobPlaintext =
|
||||
buildForwardReplyJson(includeLocalUri = true).toString()
|
||||
val optimisticReplyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
|
||||
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", replyAttachmentId)
|
||||
put("type", AttachmentType.MESSAGES.value)
|
||||
put("preview", "")
|
||||
put("width", 0)
|
||||
put("height", 0)
|
||||
put("blob", optimisticReplyBlobForDatabase)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
saveMessageToDatabase(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
opponentPublicKey = recipientPublicKey
|
||||
)
|
||||
refreshTargetDialog()
|
||||
|
||||
if (isSavedMessages && isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
|
||||
// Map: originalAttId → (newAttId, newPreview)
|
||||
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
|
||||
var fwdIdx = 0
|
||||
@@ -2631,47 +2801,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
// Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками
|
||||
val replyJsonArray = JSONArray()
|
||||
forwardMessages.forEach { fm ->
|
||||
val attachmentsArray = JSONArray()
|
||||
fm.attachments.forEach { att ->
|
||||
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
|
||||
val fwdInfo = forwardedAttMap[att.id]
|
||||
val attId = fwdInfo?.first ?: att.id
|
||||
val attPreview = fwdInfo?.second ?: att.preview
|
||||
|
||||
attachmentsArray.put(JSONObject().apply {
|
||||
put("id", attId)
|
||||
put("type", att.type.value)
|
||||
put("preview", attPreview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||
})
|
||||
}
|
||||
replyJsonArray.put(JSONObject().apply {
|
||||
put("message_id", fm.messageId)
|
||||
put("publicKey", fm.senderPublicKey)
|
||||
put("message", fm.text)
|
||||
put("timestamp", fm.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", fm.senderName)
|
||||
})
|
||||
}
|
||||
|
||||
val replyBlobPlaintext = replyJsonArray.toString()
|
||||
val replyBlobPlaintext =
|
||||
buildForwardReplyJson(
|
||||
forwardedIdMap = forwardedAttMap,
|
||||
includeLocalUri = false
|
||||
)
|
||||
.toString()
|
||||
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
|
||||
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
val replyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
messageAttachments.add(MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
))
|
||||
val finalMessageAttachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
)
|
||||
)
|
||||
|
||||
// Отправляем пакет
|
||||
val packet = PacketMessage().apply {
|
||||
@@ -2683,58 +2831,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
attachments = messageAttachments
|
||||
attachments = finalMessageAttachments
|
||||
}
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
|
||||
// Сохраняем в БД
|
||||
val attachmentsJson = JSONArray().apply {
|
||||
messageAttachments.forEach { att ->
|
||||
put(JSONObject().apply {
|
||||
put("id", att.id)
|
||||
put("type", att.type.value)
|
||||
put("preview", att.preview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", when (att.type) {
|
||||
AttachmentType.MESSAGES -> replyBlobForDatabase
|
||||
else -> ""
|
||||
})
|
||||
})
|
||||
}
|
||||
}.toString()
|
||||
val finalAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
finalMessageAttachments.forEach { att ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", att.id)
|
||||
put("type", att.type.value)
|
||||
put("preview", att.preview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put(
|
||||
"blob",
|
||||
when (att.type) {
|
||||
AttachmentType.MESSAGES ->
|
||||
replyBlobForDatabase
|
||||
else -> ""
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
|
||||
saveMessageToDatabase(
|
||||
updateMessageStatusAndAttachmentsInDb(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
opponentPublicKey = recipientPublicKey
|
||||
delivered = 1,
|
||||
attachmentsJson = finalAttachmentsJson
|
||||
)
|
||||
|
||||
// Обновляем диалог (для списка чатов) из таблицы сообщений.
|
||||
val db = RosettaDatabase.getDatabase(context)
|
||||
val dialogDao = db.dialogDao()
|
||||
if (isSavedMessages) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(sender)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) { }
|
||||
refreshTargetDialog()
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,9 @@ fun ChatsListScreen(
|
||||
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -443,10 +446,6 @@ fun ChatsListScreen(
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
|
||||
// Navigation bar
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils
|
||||
.applyNavigationBarVisibility(insetsController, context, isDarkTheme)
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
@@ -754,7 +753,10 @@ fun ChatsListScreen(
|
||||
Modifier.fillMaxSize()
|
||||
.onSizeChanged { rootSize = it }
|
||||
.background(backgroundColor)
|
||||
.navigationBarsPadding()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -812,6 +814,15 @@ fun ChatsListScreen(
|
||||
"rosetta",
|
||||
ignoreCase = true
|
||||
)
|
||||
val isFreddyOfficial =
|
||||
accountName.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
)
|
||||
// Avatar row with theme toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -925,7 +936,7 @@ fun ChatsListScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
if (accountVerified > 0 || isRosettaOfficial) {
|
||||
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(
|
||||
@@ -935,7 +946,7 @@ fun ChatsListScreen(
|
||||
VerifiedBadge(
|
||||
verified = if (accountVerified > 0) accountVerified else 1,
|
||||
size = 15,
|
||||
badgeTint = PrimaryBlue
|
||||
badgeTint = if (isDarkTheme) Color.White else PrimaryBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1230,7 +1241,14 @@ fun ChatsListScreen(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// FOOTER - Version + Update Banner
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
// Telegram-style update banner
|
||||
val curUpdate = sduUpdateState
|
||||
val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable ||
|
||||
@@ -3886,7 +3904,10 @@ fun DialogItemContent(
|
||||
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
if (dialog.verified > 0 || isRosettaOfficial) {
|
||||
val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
|
||||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
|
||||
displayName.equals("freddy", ignoreCase = true)
|
||||
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
|
||||
@@ -737,11 +737,7 @@ fun GroupInfoScreen(
|
||||
info?.title?.takeIf { it.isNotBlank() }
|
||||
?: info?.username?.takeIf { it.isNotBlank() }
|
||||
?: fallbackName
|
||||
val subtitle = when {
|
||||
isOnline -> "online"
|
||||
info?.username?.isNotBlank() == true -> "@${info.username}"
|
||||
else -> key.take(18)
|
||||
}
|
||||
val subtitle = if (isOnline) "online" else "offline"
|
||||
GroupMemberUi(
|
||||
publicKey = key,
|
||||
title = displayTitle,
|
||||
@@ -761,9 +757,11 @@ fun GroupInfoScreen(
|
||||
if (query.isBlank()) {
|
||||
true
|
||||
} else {
|
||||
val username = memberInfoByKey[member.publicKey]?.username?.lowercase().orEmpty()
|
||||
member.title.lowercase().contains(query) ||
|
||||
member.subtitle.lowercase().contains(query) ||
|
||||
member.publicKey.lowercase().contains(query)
|
||||
member.publicKey.lowercase().contains(query) ||
|
||||
username.contains(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ fun GroupSetupScreen(
|
||||
.ifBlank { normalizedUsername }
|
||||
.ifBlank { shortPublicKey(accountPublicKey) }
|
||||
}
|
||||
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you"
|
||||
val selfSubtitle = "online"
|
||||
|
||||
fun openGroup(dialogPublicKey: String, groupTitle: String) {
|
||||
onGroupOpened(
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
|
||||
import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen
|
||||
import com.rosetta.messenger.ui.chats.components.SimpleFullscreenPhotoViewer
|
||||
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
|
||||
@@ -45,6 +46,7 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.utils.NavigationModeUtils
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import android.net.Uri
|
||||
@@ -121,6 +123,13 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Telegram-style attach alert (media picker bottom sheet).
|
||||
*
|
||||
@@ -146,9 +155,13 @@ fun ChatAttachAlert(
|
||||
currentUserPublicKey: String = "",
|
||||
maxSelection: Int = 10,
|
||||
recipientName: String? = null,
|
||||
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null,
|
||||
viewModel: AttachAlertViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -195,6 +208,23 @@ fun ChatAttachAlert(
|
||||
// Keyboard helpers
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
fun hideUnderlyingChatKeyboard() {
|
||||
focusManager.clearFocus(force = true)
|
||||
activity?.currentFocus?.clearFocus()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
val servedToken =
|
||||
activity?.currentFocus?.windowToken
|
||||
?: activity?.window?.decorView?.findFocus()?.windowToken
|
||||
?: activity?.window?.decorView?.windowToken
|
||||
servedToken?.let { token ->
|
||||
imm.hideSoftInputFromWindow(token, 0)
|
||||
imm.hideSoftInputFromWindow(token, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
activity?.window?.insetsController?.hide(android.view.WindowInsets.Type.ime())
|
||||
}
|
||||
}
|
||||
|
||||
fun hideKeyboard() {
|
||||
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
|
||||
pendingCaptionFocus = false
|
||||
@@ -268,10 +298,14 @@ fun ChatAttachAlert(
|
||||
if (coordinator.emojiHeight == 0.dp) {
|
||||
// Use saved keyboard height minus nav bar (same as spacer)
|
||||
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
|
||||
val navBarPx = (context as? Activity)?.window?.decorView?.let { view ->
|
||||
androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
||||
} ?: 0
|
||||
val navBarPx = if (hasNativeNavigationBar) {
|
||||
(context as? Activity)?.window?.decorView?.let { view ->
|
||||
androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
||||
} ?: 0
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0
|
||||
coordinator.emojiHeight = if (effectivePx > 0) {
|
||||
with(density) { effectivePx.toDp() }
|
||||
@@ -293,7 +327,11 @@ fun ChatAttachAlert(
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeight = configuration.screenHeightDp.dp
|
||||
val screenHeightPx = with(density) { screenHeight.toPx() }
|
||||
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
|
||||
val navigationBarInsetPx = if (hasNativeNavigationBar) {
|
||||
WindowInsets.navigationBars.getBottom(density).toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
|
||||
|
||||
val collapsedHeightPx = screenHeightPx * 0.72f
|
||||
@@ -456,6 +494,9 @@ fun ChatAttachAlert(
|
||||
|
||||
LaunchedEffect(showSheet) {
|
||||
if (showSheet && state.editingItem == null) {
|
||||
// Close chat keyboard before showing picker so no IME layer remains under it.
|
||||
hideUnderlyingChatKeyboard()
|
||||
kotlinx.coroutines.delay(16)
|
||||
// Telegram pattern: set ADJUST_NOTHING on Activity before showing popup
|
||||
// This prevents the system from resizing the layout when focus changes
|
||||
activity?.window?.let { win ->
|
||||
@@ -668,22 +709,40 @@ fun ChatAttachAlert(
|
||||
val origNavBarColor = window?.navigationBarColor ?: 0
|
||||
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
|
||||
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
|
||||
val origContrastEnforced =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window?.isNavigationBarContrastEnforced
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onDispose {
|
||||
window?.statusBarColor = origStatusBarColor
|
||||
window?.navigationBarColor = origNavBarColor
|
||||
insetsController?.isAppearanceLightNavigationBars = origLightNav
|
||||
insetsController?.isAppearanceLightStatusBars = origLightStatus
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
|
||||
window?.isNavigationBarContrastEnforced = origContrastEnforced
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldShow) {
|
||||
if (!shouldShow) return@LaunchedEffect
|
||||
LaunchedEffect(shouldShow, state.editingItem) {
|
||||
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
||||
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) }
|
||||
.collect { (alpha, fullScreen, dark) ->
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
@@ -693,8 +752,28 @@ fun ChatAttachAlert(
|
||||
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
|
||||
if (hasNativeNavigationBar) {
|
||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,7 +789,7 @@ fun ChatAttachAlert(
|
||||
// POPUP RENDERING
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
if (shouldShow) {
|
||||
if (shouldShow && state.editingItem == null) {
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
onDismissRequest = {
|
||||
@@ -823,8 +902,9 @@ fun ChatAttachAlert(
|
||||
} else keyboardSpacerDp
|
||||
|
||||
// When keyboard or emoji is open, nav bar is behind — don't pad for it
|
||||
val navBarDp = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0.dp
|
||||
else with(density) { navigationBarInsetPx.toDp() }
|
||||
val navInsetPxForSheet = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0f
|
||||
else navigationBarInsetPx
|
||||
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -833,13 +913,12 @@ fun ChatAttachAlert(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { requestClose() }
|
||||
.padding(bottom = navBarDp),
|
||||
) { requestClose() },
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
// Sheet height stays constant — keyboard space is handled by
|
||||
// internal Spacer, not by shrinking the container (Telegram approach).
|
||||
val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx)
|
||||
val visibleSheetHeightPx = (sheetHeightPx.value + navInsetPxForSheet).coerceAtLeast(minHeightPx)
|
||||
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||
val expandProgress =
|
||||
@@ -976,8 +1055,19 @@ fun ChatAttachAlert(
|
||||
},
|
||||
onItemClick = { item, position ->
|
||||
if (!item.isVideo) {
|
||||
thumbnailPosition = position
|
||||
viewModel.setEditingItem(item)
|
||||
hideKeyboard()
|
||||
if (onPhotoPreviewRequested != null) {
|
||||
val photoItems = state.visibleMediaItems.filter { !it.isVideo }
|
||||
val photoUris = photoItems.map { it.uri }
|
||||
val currentIndex =
|
||||
photoItems.indexOfFirst { it.id == item.id }
|
||||
.takeIf { it >= 0 }
|
||||
?: photoUris.indexOf(item.uri).coerceAtLeast(0)
|
||||
onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex)
|
||||
} else {
|
||||
thumbnailPosition = position
|
||||
viewModel.setEditingItem(item)
|
||||
}
|
||||
} else {
|
||||
viewModel.toggleSelection(item.id, maxSelection)
|
||||
}
|
||||
@@ -1089,6 +1179,9 @@ fun ChatAttachAlert(
|
||||
if (!coordinator.isEmojiBoxVisible) {
|
||||
Spacer(modifier = Modifier.height(keyboardSpacerDp))
|
||||
}
|
||||
if (navInsetDpForSheet > 0.dp) {
|
||||
Spacer(modifier = Modifier.height(navInsetDpForSheet))
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// ── Floating Send Button ──
|
||||
@@ -1112,7 +1205,7 @@ fun ChatAttachAlert(
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = bottomInputPadding)
|
||||
.padding(bottom = bottomInputPadding + navInsetDpForSheet)
|
||||
)
|
||||
|
||||
} // end Box sheet container
|
||||
@@ -1173,45 +1266,22 @@ fun ChatAttachAlert(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
state.editingItem?.let { item ->
|
||||
ImageEditorScreen(
|
||||
val galleryPhotoItems = state.visibleMediaItems.filter { !it.isVideo }
|
||||
val galleryPhotoUris = galleryPhotoItems.map { it.uri }
|
||||
val initialGalleryIndex =
|
||||
galleryPhotoItems.indexOfFirst { it.id == item.id }
|
||||
.takeIf { it >= 0 }
|
||||
?: galleryPhotoUris.indexOf(item.uri).coerceAtLeast(0)
|
||||
SimpleFullscreenPhotoViewer(
|
||||
imageUri = item.uri,
|
||||
sourceThumbnail = thumbnailPosition,
|
||||
galleryImageUris = galleryPhotoUris,
|
||||
initialGalleryIndex = initialGalleryIndex,
|
||||
onDismiss = {
|
||||
viewModel.setEditingItem(null)
|
||||
thumbnailPosition = null
|
||||
shouldShow = true
|
||||
},
|
||||
onSave = { editedUri ->
|
||||
viewModel.setEditingItem(null)
|
||||
thumbnailPosition = null
|
||||
if (onMediaSelectedWithCaption == null) {
|
||||
previewPhotoUri = editedUri
|
||||
} else {
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelected(listOf(mediaItem), "")
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
|
||||
viewModel.setEditingItem(null)
|
||||
thumbnailPosition = null
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelectedWithCaption(mediaItem, caption)
|
||||
onDismiss()
|
||||
} else null,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showCaptionInput = onMediaSelectedWithCaption != null,
|
||||
recipientName = recipientName,
|
||||
thumbnailPosition = thumbnailPosition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview(
|
||||
val cached = ImageBitmapCache.get(cacheKey)
|
||||
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
|
||||
|
||||
// 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл
|
||||
if (attachment.localUri.isNotEmpty()) {
|
||||
val localBitmap =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver
|
||||
.openInputStream(
|
||||
android.net.Uri.parse(
|
||||
attachment.localUri
|
||||
)
|
||||
)
|
||||
?.use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
if (localBitmap != null) {
|
||||
imageBitmap = localBitmap
|
||||
ImageBitmapCache.put(cacheKey, localBitmap)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Try local file cache first
|
||||
try {
|
||||
|
||||
@@ -829,7 +829,7 @@ fun ImageEditorScreen(
|
||||
* Telegram-style toolbar - icons only, no labels
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramToolbar(
|
||||
internal fun TelegramToolbar(
|
||||
currentTool: EditorTool,
|
||||
showCaptionInput: Boolean,
|
||||
isSaving: Boolean,
|
||||
@@ -958,7 +958,7 @@ private fun TelegramToolButton(
|
||||
* Telegram-style color picker with brush size
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramColorPicker(
|
||||
internal fun TelegramColorPicker(
|
||||
selectedColor: Color,
|
||||
brushSize: Float,
|
||||
onColorSelected: (Color) -> Unit,
|
||||
@@ -1044,7 +1044,7 @@ private fun TelegramColorPicker(
|
||||
* Telegram-style rotate bar
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramRotateBar(
|
||||
internal fun TelegramRotateBar(
|
||||
onRotateLeft: () -> Unit,
|
||||
onRotateRight: () -> Unit,
|
||||
onFlipHorizontal: () -> Unit,
|
||||
@@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld(
|
||||
}
|
||||
|
||||
/** Save edited image synchronously (with all editor changes). */
|
||||
private suspend fun saveEditedImageSync(
|
||||
internal suspend fun saveEditedImageSync(
|
||||
context: Context,
|
||||
photoEditor: PhotoEditor?,
|
||||
photoEditorView: PhotoEditorView?,
|
||||
@@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair<Int, In
|
||||
}
|
||||
|
||||
/** Launch UCrop activity */
|
||||
private fun launchCrop(
|
||||
internal fun launchCrop(
|
||||
context: Context,
|
||||
sourceUri: Uri,
|
||||
launcher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||
import com.rosetta.messenger.ui.utils.NavigationModeUtils
|
||||
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -82,6 +83,13 @@ import kotlin.math.roundToInt
|
||||
private const val TAG = "MediaPickerBottomSheet"
|
||||
private const val ALL_MEDIA_ALBUM_ID = 0L
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Media item from gallery
|
||||
*/
|
||||
@@ -125,9 +133,13 @@ fun MediaPickerBottomSheet(
|
||||
onAvatarClick: () -> Unit = {},
|
||||
currentUserPublicKey: String = "",
|
||||
maxSelection: Int = 10,
|
||||
recipientName: String? = null
|
||||
recipientName: String? = null,
|
||||
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
@@ -287,7 +299,11 @@ fun MediaPickerBottomSheet(
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeight = configuration.screenHeightDp.dp
|
||||
val screenHeightPx = with(density) { screenHeight.toPx() }
|
||||
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
|
||||
val navigationBarInsetPx = if (hasNativeNavigationBar) {
|
||||
WindowInsets.navigationBars.getBottom(density).toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
|
||||
|
||||
// 🔄 Высоты в пикселях для точного контроля
|
||||
@@ -376,6 +392,9 @@ fun MediaPickerBottomSheet(
|
||||
// Запускаем анимацию когда showSheet меняется
|
||||
LaunchedEffect(showSheet) {
|
||||
if (showSheet && editingItem == null) {
|
||||
// Ensure IME from chat is closed before picker opens.
|
||||
hideKeyboard()
|
||||
delay(16)
|
||||
shouldShow = true
|
||||
isClosing = false
|
||||
showAlbumMenu = false
|
||||
@@ -554,12 +573,21 @@ fun MediaPickerBottomSheet(
|
||||
val origNavBarColor = window?.navigationBarColor ?: 0
|
||||
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
|
||||
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
|
||||
val origContrastEnforced =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window?.isNavigationBarContrastEnforced
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
onDispose {
|
||||
window?.statusBarColor = origStatusBarColor
|
||||
window?.navigationBarColor = origNavBarColor
|
||||
insetsController?.isAppearanceLightNavigationBars = origLightNav
|
||||
insetsController?.isAppearanceLightStatusBars = origLightStatus
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
|
||||
window?.isNavigationBarContrastEnforced = origContrastEnforced
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onDispose { }
|
||||
@@ -567,12 +595,21 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
|
||||
// Reactive updates — single snapshotFlow drives ALL system bar colors
|
||||
LaunchedEffect(shouldShow) {
|
||||
if (!shouldShow) return@LaunchedEffect
|
||||
LaunchedEffect(shouldShow, editingItem) {
|
||||
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
||||
|
||||
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) }
|
||||
.collect { (alpha, fullScreen, dark) ->
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
// Full screen: status bar = picker background, seamless
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
@@ -584,16 +621,33 @@ fun MediaPickerBottomSheet(
|
||||
)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
// Navigation bar always follows scrim
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
|
||||
)
|
||||
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
|
||||
if (hasNativeNavigationBar) {
|
||||
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
} else {
|
||||
// Telegram-like on gesture navigation: transparent stable nav area.
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Используем Popup для показа поверх клавиатуры
|
||||
if (shouldShow) {
|
||||
if (shouldShow && editingItem == null) {
|
||||
// BackHandler для закрытия по back
|
||||
BackHandler {
|
||||
if (isExpanded) {
|
||||
@@ -627,7 +681,8 @@ fun MediaPickerBottomSheet(
|
||||
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
|
||||
val appliedKeyboardInsetPx =
|
||||
if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f
|
||||
val navBarDp = with(density) { navigationBarInsetPx.toDp() }
|
||||
val navInsetPxForSheet = if (appliedKeyboardInsetPx > 0f) 0f else navigationBarInsetPx
|
||||
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
|
||||
|
||||
// Полноэкранный контейнер с мягким затемнением
|
||||
// background BEFORE padding — scrim covers area behind keyboard too
|
||||
@@ -638,14 +693,14 @@ fun MediaPickerBottomSheet(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { requestClose() }
|
||||
.padding(bottom = navBarDp),
|
||||
) { requestClose() },
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
// Subtract keyboard from sheet height so it fits in the resized viewport.
|
||||
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
|
||||
val visibleSheetHeightPx =
|
||||
(sheetHeightPx.value - appliedKeyboardInsetPx).coerceAtLeast(minHeightPx)
|
||||
(sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet)
|
||||
.coerceAtLeast(minHeightPx)
|
||||
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||
val expandProgress =
|
||||
@@ -986,8 +1041,19 @@ fun MediaPickerBottomSheet(
|
||||
},
|
||||
onItemClick = { item, position ->
|
||||
if (!item.isVideo) {
|
||||
thumbnailPosition = position
|
||||
editingItem = item
|
||||
hideKeyboard()
|
||||
if (onPhotoPreviewRequested != null) {
|
||||
val photoItems = visibleMediaItems.filter { !it.isVideo }
|
||||
val photoUris = photoItems.map { it.uri }
|
||||
val currentIndex =
|
||||
photoItems.indexOfFirst { it.id == item.id }
|
||||
.takeIf { it >= 0 }
|
||||
?: photoUris.indexOf(item.uri).coerceAtLeast(0)
|
||||
onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex)
|
||||
} else {
|
||||
thumbnailPosition = position
|
||||
editingItem = item
|
||||
}
|
||||
} else {
|
||||
// Videos don't have photo editor in this flow.
|
||||
toggleSelection(item.id)
|
||||
@@ -1145,6 +1211,9 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (navInsetDpForSheet > 0.dp) {
|
||||
Spacer(modifier = Modifier.height(navInsetDpForSheet))
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
@@ -1163,7 +1232,7 @@ fun MediaPickerBottomSheet(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 14.dp, bottom = 8.dp)
|
||||
.padding(end = 14.dp, bottom = 8.dp + navInsetDpForSheet)
|
||||
.graphicsLayer {
|
||||
scaleX = sendScale
|
||||
scaleY = sendScale
|
||||
@@ -1279,48 +1348,16 @@ fun MediaPickerBottomSheet(
|
||||
)
|
||||
}
|
||||
|
||||
// Image Editor FULLSCREEN overlay для фото из галереи
|
||||
// ImageEditorScreen wraps itself in a Dialog internally — no external wrapper needed
|
||||
// Fullscreen preview для выбранной фото из галереи (чистый экран без тулбаров).
|
||||
editingItem?.let { item ->
|
||||
ImageEditorScreen(
|
||||
SimpleFullscreenPhotoViewer(
|
||||
imageUri = item.uri,
|
||||
sourceThumbnail = thumbnailPosition,
|
||||
onDismiss = {
|
||||
editingItem = null
|
||||
thumbnailPosition = null
|
||||
shouldShow = true
|
||||
},
|
||||
onSave = { editedUri ->
|
||||
editingItem = null
|
||||
thumbnailPosition = null
|
||||
if (onMediaSelectedWithCaption == null) {
|
||||
previewPhotoUri = editedUri
|
||||
} else {
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelected(listOf(mediaItem), "")
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
|
||||
editingItem = null
|
||||
thumbnailPosition = null
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelectedWithCaption(mediaItem, caption)
|
||||
onDismiss()
|
||||
} else null,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showCaptionInput = onMediaSelectedWithCaption != null,
|
||||
recipientName = recipientName,
|
||||
thumbnailPosition = thumbnailPosition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2208,6 +2245,9 @@ fun PhotoPreviewWithCaptionScreen(
|
||||
val backgroundColor = if (isDarkTheme) Color.Black else Color.White
|
||||
|
||||
val context = LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val density = LocalDensity.current
|
||||
@@ -2292,7 +2332,8 @@ fun PhotoPreviewWithCaptionScreen(
|
||||
|
||||
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден
|
||||
val shouldUseImePadding = !coordinator.isEmojiBoxVisible
|
||||
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
|
||||
val shouldAddNavBarPadding =
|
||||
hasNativeNavigationBar && !isKeyboardVisible && !coordinator.isEmojiBoxVisible
|
||||
|
||||
// Логируем состояние при каждой рекомпозиции
|
||||
Surface(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,8 @@ 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 androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||
import coil.compose.AsyncImage
|
||||
@@ -88,6 +90,7 @@ fun MessageInputBar(
|
||||
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||
isForwardMode: Boolean = false,
|
||||
onCloseReply: () -> Unit = {},
|
||||
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit = {},
|
||||
chatTitle: String = "",
|
||||
isBlocked: Boolean = false,
|
||||
showEmojiPicker: Boolean = false,
|
||||
@@ -104,7 +107,8 @@ fun MessageInputBar(
|
||||
mentionCandidates: List<MentionCandidate> = emptyList(),
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
inputFocusTrigger: Int = 0,
|
||||
suppressKeyboard: Boolean = false
|
||||
suppressKeyboard: Boolean = false,
|
||||
hasNativeNavigationBar: Boolean = true
|
||||
) {
|
||||
val hasReply = replyMessages.isNotEmpty()
|
||||
val liveReplyMessages =
|
||||
@@ -217,6 +221,11 @@ fun MessageInputBar(
|
||||
|
||||
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
var showForwardCancelDialog by remember { mutableStateOf(false) }
|
||||
var forwardCancelDialogCount by remember { mutableIntStateOf(0) }
|
||||
var forwardCancelDialogMessages by remember {
|
||||
mutableStateOf<List<ChatViewModel.ReplyMessage>>(emptyList())
|
||||
}
|
||||
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
|
||||
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
|
||||
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
|
||||
@@ -367,7 +376,10 @@ fun MessageInputBar(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.padding(bottom = 20.dp)
|
||||
.navigationBarsPadding(),
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
@@ -398,7 +410,10 @@ fun MessageInputBar(
|
||||
)
|
||||
)
|
||||
|
||||
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
|
||||
val shouldAddNavBarPadding =
|
||||
hasNativeNavigationBar &&
|
||||
!isKeyboardVisible &&
|
||||
!coordinator.isEmojiBoxVisible
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -654,7 +669,18 @@ fun MessageInputBar(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onCloseReply
|
||||
onClick = {
|
||||
if (panelIsForwardMode && panelReplyMessages.isNotEmpty()) {
|
||||
val sourceMessages =
|
||||
if (replyMessages.isNotEmpty()) replyMessages
|
||||
else panelReplyMessages
|
||||
forwardCancelDialogCount = sourceMessages.size
|
||||
forwardCancelDialogMessages = sourceMessages
|
||||
showForwardCancelDialog = true
|
||||
} else {
|
||||
onCloseReply()
|
||||
}
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -758,6 +784,122 @@ fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
if (showForwardCancelDialog) {
|
||||
val titleText =
|
||||
if (forwardCancelDialogCount == 1) "1 message"
|
||||
else "$forwardCancelDialogCount messages"
|
||||
val bodyText =
|
||||
"What would you like to do with $titleText from your chat with ${chatTitle.ifBlank { "this user" }}?"
|
||||
val popupInteraction = remember { MutableInteractionSource() }
|
||||
val cardInteraction = remember { MutableInteractionSource() }
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
val bodyColor = if (isDarkTheme) Color(0xFFD1D1D6) else Color(0xFF3C3C43)
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.Center,
|
||||
onDismissRequest = {
|
||||
showForwardCancelDialog = false
|
||||
forwardCancelDialogMessages = emptyList()
|
||||
},
|
||||
properties =
|
||||
PopupProperties(
|
||||
focusable = false,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
clippingEnabled = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.36f))
|
||||
.clickable(
|
||||
interactionSource = popupInteraction,
|
||||
indication = null
|
||||
) { showForwardCancelDialog = false },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.88f)
|
||||
.widthIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(
|
||||
interactionSource = cardInteraction,
|
||||
indication = null
|
||||
) {},
|
||||
color = cardColor,
|
||||
shadowElevation = 18.dp
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Text(
|
||||
text = titleText,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = bodyText,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 21.sp,
|
||||
color = bodyColor,
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Divider(thickness = 0.5.dp, color = dividerColor)
|
||||
TextButton(
|
||||
onClick = {
|
||||
showForwardCancelDialog = false
|
||||
val panelMessages =
|
||||
if (replyMessages.isNotEmpty()) {
|
||||
replyMessages
|
||||
} else if (forwardCancelDialogMessages.isNotEmpty()) {
|
||||
forwardCancelDialogMessages
|
||||
} else if (liveReplyMessages.isNotEmpty()) {
|
||||
liveReplyMessages
|
||||
} else {
|
||||
animatedReplyMessages
|
||||
}
|
||||
onShowForwardOptions(panelMessages)
|
||||
forwardCancelDialogMessages = emptyList()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Show forwarding options",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
showForwardCancelDialog = false
|
||||
forwardCancelDialogMessages = emptyList()
|
||||
onCloseReply()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel Forwarding",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFFFF5D73)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EMOJI PICKER
|
||||
if (!isBlocked) {
|
||||
AnimatedKeyboardTransition(
|
||||
|
||||
@@ -45,8 +45,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.ui.theme.*
|
||||
@@ -124,8 +122,11 @@ fun OnboardingScreen(
|
||||
|
||||
// Animate navigation bar color starting at 80% of wave animation
|
||||
val view = LocalView.current
|
||||
val isGestureNavigation = remember(view.context) {
|
||||
NavigationModeUtils.isGestureNavigation(view.context)
|
||||
}
|
||||
LaunchedEffect(isTransitioning, transitionProgress) {
|
||||
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
||||
if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
// Map 0.8-1.0 to 0-1 for smooth interpolation
|
||||
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
|
||||
@@ -163,7 +164,10 @@ fun OnboardingScreen(
|
||||
|
||||
// Navigation bar: показываем только если есть нативные кнопки
|
||||
NavigationModeUtils.applyNavigationBarVisibility(
|
||||
insetsController, view.context, isDarkTheme
|
||||
window = window,
|
||||
insetsController = insetsController,
|
||||
context = view.context,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,10 +177,12 @@ fun OnboardingScreen(
|
||||
if (!view.isInEditMode) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
window.navigationBarColor =
|
||||
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
NavigationModeUtils.applyNavigationBarVisibility(
|
||||
window = window,
|
||||
insetsController = insetsController,
|
||||
context = view.context,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader(
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
||||
@@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (verified > 0 || isRosettaOfficial) {
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (verified > 0) verified else 1,
|
||||
|
||||
@@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader(
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so gradient can fade during expansion
|
||||
@@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader(
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (verified > 0 || isRosettaOfficial) {
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (verified > 0) verified else 2,
|
||||
|
||||
@@ -68,15 +68,21 @@ fun RosettaAndroidTheme(
|
||||
val view = LocalView.current
|
||||
val context = LocalContext.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
DisposableEffect(darkTheme, view, context) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
// Make status bar transparent for wave animation overlay
|
||||
window.statusBarColor = AndroidColor.TRANSPARENT
|
||||
// Note: isAppearanceLightStatusBars is managed per-screen, not globally
|
||||
|
||||
// Navigation bar: показываем только если есть нативные кнопки
|
||||
NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme)
|
||||
NavigationModeUtils.applyNavigationBarVisibility(
|
||||
window = window,
|
||||
insetsController = insetsController,
|
||||
context = context,
|
||||
isDarkTheme = darkTheme
|
||||
)
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.rosetta.messenger.ui.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -58,12 +62,42 @@ object NavigationModeUtils {
|
||||
* Показывает navigation bar на всех устройствах.
|
||||
*/
|
||||
fun applyNavigationBarVisibility(
|
||||
window: Window,
|
||||
insetsController: WindowInsetsControllerCompat,
|
||||
context: Context,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val gestureNavigation = isGestureNavigation(context)
|
||||
val decorView = window.decorView
|
||||
|
||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
if (gestureNavigation) {
|
||||
// In gesture mode we keep a transparent nav bar and fixed white handle.
|
||||
// This avoids the "jump" effect during theme switching.
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
insetsController.isAppearanceLightNavigationBars = false
|
||||
val newFlags =
|
||||
decorView.systemUiVisibility or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
if (newFlags != decorView.systemUiVisibility) {
|
||||
decorView.systemUiVisibility = newFlags
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
} else {
|
||||
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
val newFlags =
|
||||
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
|
||||
if (newFlags != decorView.systemUiVisibility) {
|
||||
decorView.systemUiVisibility = newFlags
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user