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