From 160ba4e2e7df7b51c3bba49dbaef1259dc42c06c Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 13 Mar 2026 18:44:20 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=20fullscreen=20=D1=84=D0=BE=D1=82=D0=BE-=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BB=D0=B5=D0=B8=20=D0=B8=20=D1=83?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20optimistic=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 52 +- .../messenger/ui/chats/ChatViewModel.kt | 291 +++++++--- .../messenger/ui/chats/ChatsListScreen.kt | 39 +- .../ui/chats/attach/ChatAttachAlert.kt | 47 +- .../chats/components/ChatDetailComponents.kt | 23 + .../ui/chats/components/ImageEditorScreen.kt | 10 +- .../components/MediaPickerBottomSheet.kt | 45 +- .../components/SimpleFullscreenPhotoViewer.kt | 549 +++++++++++++++++- .../ui/chats/input/ChatDetailInput.kt | 13 +- .../ui/onboarding/OnboardingScreen.kt | 22 +- .../ui/settings/OtherProfileScreen.kt | 5 +- .../messenger/ui/settings/ProfileScreen.kt | 5 +- .../com/rosetta/messenger/ui/theme/Theme.kt | 12 +- .../messenger/ui/utils/NavigationModeUtils.kt | 36 +- 14 files changed, 978 insertions(+), 171 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 12f3706..edf872d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -296,6 +296,7 @@ fun ChatDetailScreen( var imageViewerImages by remember { mutableStateOf>(emptyList()) } var simplePickerPreviewUri by remember { mutableStateOf(null) } var simplePickerPreviewSourceThumb by remember { mutableStateOf(null) } + var simplePickerPreviewCaption by remember { mutableStateOf("") } // 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме if (!view.isInEditMode) { @@ -388,6 +389,13 @@ fun ChatDetailScreen( onImageViewerChanged(shouldLockParentSwipeBack) } + LaunchedEffect(simplePickerPreviewUri) { + if (simplePickerPreviewUri != null) { + showContextMenu = false + contextMenuMessage = null + } + } + DisposableEffect(Unit) { onDispose { onImageViewerChanged(false) } } @@ -1986,14 +1994,7 @@ fun ChatDetailScreen( it.type != AttachmentType .MESSAGES - } - .map { - attachment -> - attachment.copy( - localUri = - "" - ) - } + } ) } } else { @@ -2025,14 +2026,7 @@ fun ChatDetailScreen( it.type != AttachmentType .MESSAGES - } - .map { - attachment -> - attachment.copy( - localUri = - "" - ) - } + } )) } } @@ -2526,6 +2520,9 @@ fun ChatDetailScreen( avatarRepository = avatarRepository, onLongClick = { + if (simplePickerPreviewUri != null) { + return@MessageBubble + } // 📳 Haptic feedback при долгом нажатии // Не разрешаем выделять avatar-сообщения val hasAvatar = @@ -2566,6 +2563,9 @@ fun ChatDetailScreen( ) }, onClick = { + if (simplePickerPreviewUri != null) { + return@MessageBubble + } if (shouldIgnoreTapAfterLongPress( selectionKey ) @@ -3022,7 +3022,10 @@ fun ChatDetailScreen( onPhotoPreviewRequested = { uri, sourceThumb -> hideInputOverlays() showMediaPicker = false + showContextMenu = false + contextMenuMessage = null simplePickerPreviewSourceThumb = sourceThumb + simplePickerPreviewCaption = "" simplePickerPreviewUri = uri } ) @@ -3072,7 +3075,10 @@ fun ChatDetailScreen( onPhotoPreviewRequested = { uri, sourceThumb -> hideInputOverlays() showMediaPicker = false + showContextMenu = false + contextMenuMessage = null simplePickerPreviewSourceThumb = sourceThumb + simplePickerPreviewCaption = "" simplePickerPreviewUri = uri } ) @@ -3344,9 +3350,23 @@ fun ChatDetailScreen( imageUri = previewUri, sourceThumbnail = simplePickerPreviewSourceThumb, 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 = "" + inputFocusTrigger++ + }, onDismiss = { simplePickerPreviewUri = null simplePickerPreviewSourceThumb = null + simplePickerPreviewCaption = "" + inputFocusTrigger++ } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 69f1b0a..b407b47 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -1582,10 +1582,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val attBlob = attJson.optString("blob", "") val attWidth = attJson.optInt("width", 0) val attHeight = attJson.optInt("height", 0) + val attLocalUri = attJson.optString("localUri", "") if (attId.isNotEmpty()) { fwdAttachments.add(MessageAttachment( id = attId, type = attType, preview = attPreview, - blob = attBlob, width = attWidth, height = attHeight + blob = attBlob, width = attWidth, height = attHeight, + localUri = attLocalUri )) } } @@ -1662,6 +1664,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val attBlob = attJson.optString("blob", "") val attWidth = attJson.optInt("width", 0) val attHeight = attJson.optInt("height", 0) + val attLocalUri = attJson.optString("localUri", "") if (attId.isNotEmpty()) { replyAttachmentsFromJson.add( @@ -1671,7 +1674,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { preview = attPreview, blob = attBlob, width = attWidth, - height = attHeight + height = attHeight, + localUri = attLocalUri ) ) } @@ -2566,12 +2570,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val privateKey = myPrivateKey ?: return if (forwardMessages.isEmpty()) return + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) + val timestamp = System.currentTimeMillis() + val isCurrentDialogTarget = recipientPublicKey == opponentKey + viewModelScope.launch(Dispatchers.IO) { try { val context = getApplication() - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() val isSavedMessages = (sender == recipientPublicKey) + val db = RosettaDatabase.getDatabase(context) + val dialogDao = db.dialogDao() + + suspend fun refreshTargetDialog() { + if (isSavedMessages) { + dialogDao.updateSavedMessagesDialogFromMessages(sender) + } else { + dialogDao.updateDialogFromMessages(sender, recipientPublicKey) + } + } // Шифрование (пустой текст для forward) val encryptionContext = @@ -2584,11 +2600,133 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val encryptedKey = encryptionContext.encryptedKey val aesChachaKey = encryptionContext.aesChachaKey val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + val replyAttachmentId = "reply_${timestamp}" - val messageAttachments = mutableListOf() - var replyBlobForDatabase = "" + fun buildForwardReplyJson( + forwardedIdMap: Map> = emptyMap(), + includeLocalUri: Boolean + ): JSONArray { + val replyJsonArray = JSONArray() + forwardMessages.forEach { fm -> + val attachmentsArray = JSONArray() + fm.attachments.forEach { att -> + val fwdInfo = forwardedIdMap[att.id] + val attId = fwdInfo?.first ?: att.id + val attPreview = fwdInfo?.second ?: att.preview - // 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob + attachmentsArray.put( + JSONObject().apply { + put("id", attId) + put("type", att.type.value) + put("preview", attPreview) + put("width", att.width) + put("height", att.height) + put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "") + if (includeLocalUri && att.localUri.isNotEmpty()) { + put("localUri", att.localUri) + } + } + ) + } + replyJsonArray.put( + JSONObject().apply { + put("message_id", fm.messageId) + put("publicKey", fm.senderPublicKey) + put("message", fm.text) + put("timestamp", fm.timestamp) + put("attachments", attachmentsArray) + put("forwarded", true) + put("senderName", fm.senderName) + } + ) + } + return replyJsonArray + } + + // 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге + if (isCurrentDialogTarget) { + val optimisticForwardedMessages = + forwardMessages.map { fm -> + val senderDisplayName = + fm.senderName.ifEmpty { + if (fm.senderPublicKey == sender) "You" else "User" + } + ReplyData( + messageId = fm.messageId, + senderName = senderDisplayName, + text = fm.text, + isFromMe = fm.senderPublicKey == sender, + isForwarded = true, + forwardedFromName = senderDisplayName, + attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES }, + senderPublicKey = fm.senderPublicKey, + recipientPrivateKey = privateKey + ) + } + withContext(Dispatchers.Main) { + addMessageSafely( + ChatMessage( + id = messageId, + text = "", + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + forwardedMessages = optimisticForwardedMessages + ) + ) + } + } + + // 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу + val optimisticReplyBlobPlaintext = + buildForwardReplyJson(includeLocalUri = true).toString() + val optimisticReplyBlobForDatabase = + CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey) + + val optimisticAttachmentsJson = + JSONArray() + .apply { + put( + JSONObject().apply { + put("id", replyAttachmentId) + put("type", AttachmentType.MESSAGES.value) + put("preview", "") + put("width", 0) + put("height", 0) + put("blob", optimisticReplyBlobForDatabase) + } + ) + } + .toString() + + saveMessageToDatabase( + messageId = messageId, + text = "", + encryptedContent = encryptedContent, + encryptedKey = + if (encryptionContext.isGroup) { + buildStoredGroupKey( + encryptionContext.attachmentPassword, + privateKey + ) + } else { + encryptedKey + }, + timestamp = timestamp, + isFromMe = true, + delivered = if (isSavedMessages) 1 else 0, + attachmentsJson = optimisticAttachmentsJson, + opponentPublicKey = recipientPublicKey + ) + refreshTargetDialog() + + if (isSavedMessages && isCurrentDialogTarget) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + } + + // 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag // Map: originalAttId → (newAttId, newPreview) val forwardedAttMap = mutableMapOf>() var fwdIdx = 0 @@ -2631,47 +2769,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - // Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками - val replyJsonArray = JSONArray() - forwardMessages.forEach { fm -> - val attachmentsArray = JSONArray() - fm.attachments.forEach { att -> - // Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag) - val fwdInfo = forwardedAttMap[att.id] - val attId = fwdInfo?.first ?: att.id - val attPreview = fwdInfo?.second ?: att.preview - - attachmentsArray.put(JSONObject().apply { - put("id", attId) - put("type", att.type.value) - put("preview", attPreview) - put("width", att.width) - put("height", att.height) - put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "") - }) - } - replyJsonArray.put(JSONObject().apply { - put("message_id", fm.messageId) - put("publicKey", fm.senderPublicKey) - put("message", fm.text) - put("timestamp", fm.timestamp) - put("attachments", attachmentsArray) - put("forwarded", true) - put("senderName", fm.senderName) - }) - } - - val replyBlobPlaintext = replyJsonArray.toString() + val replyBlobPlaintext = + buildForwardReplyJson( + forwardedIdMap = forwardedAttMap, + includeLocalUri = false + ) + .toString() val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext) - replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) + val replyBlobForDatabase = + CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) - val replyAttachmentId = "reply_${timestamp}" - messageAttachments.add(MessageAttachment( - id = replyAttachmentId, - blob = encryptedReplyBlob, - type = AttachmentType.MESSAGES, - preview = "" - )) + val finalMessageAttachments = + listOf( + MessageAttachment( + id = replyAttachmentId, + blob = encryptedReplyBlob, + type = AttachmentType.MESSAGES, + preview = "" + ) + ) // Отправляем пакет val packet = PacketMessage().apply { @@ -2683,58 +2799,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { this.timestamp = timestamp this.privateKey = privateKeyHash this.messageId = messageId - attachments = messageAttachments + attachments = finalMessageAttachments } if (!isSavedMessages) { ProtocolManager.send(packet) } - // Сохраняем в БД - val attachmentsJson = JSONArray().apply { - messageAttachments.forEach { att -> - put(JSONObject().apply { - put("id", att.id) - put("type", att.type.value) - put("preview", att.preview) - put("width", att.width) - put("height", att.height) - put("blob", when (att.type) { - AttachmentType.MESSAGES -> replyBlobForDatabase - else -> "" - }) - }) - } - }.toString() + val finalAttachmentsJson = + JSONArray() + .apply { + finalMessageAttachments.forEach { att -> + put( + JSONObject().apply { + put("id", att.id) + put("type", att.type.value) + put("preview", att.preview) + put("width", att.width) + put("height", att.height) + put( + "blob", + when (att.type) { + AttachmentType.MESSAGES -> + replyBlobForDatabase + else -> "" + } + ) + } + ) + } + } + .toString() - saveMessageToDatabase( + updateMessageStatusAndAttachmentsInDb( messageId = messageId, - text = "", - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - privateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = attachmentsJson, - opponentPublicKey = recipientPublicKey + delivered = 1, + attachmentsJson = finalAttachmentsJson ) - // Обновляем диалог (для списка чатов) из таблицы сообщений. - val db = RosettaDatabase.getDatabase(context) - val dialogDao = db.dialogDao() - if (isSavedMessages) { - dialogDao.updateSavedMessagesDialogFromMessages(sender) - } else { - dialogDao.updateDialogFromMessages(sender, recipientPublicKey) + if (isCurrentDialogTarget) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } } - } catch (e: Exception) { } + refreshTargetDialog() + } catch (e: Exception) { + if (isCurrentDialogTarget) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + } + updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index c26f04b..245452c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -273,6 +273,9 @@ fun ChatsListScreen( val view = androidx.compose.ui.platform.LocalView.current val context = androidx.compose.ui.platform.LocalContext.current + val hasNativeNavigationBar = remember(context) { + com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context) + } val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -443,10 +446,6 @@ fun ChatsListScreen( insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT - // Navigation bar - com.rosetta.messenger.ui.utils.NavigationModeUtils - .applyNavigationBarVisibility(insetsController, context, isDarkTheme) - onDispose { } } @@ -754,7 +753,10 @@ fun ChatsListScreen( Modifier.fillMaxSize() .onSizeChanged { rootSize = it } .background(backgroundColor) - .navigationBarsPadding() + .then( + if (hasNativeNavigationBar) Modifier.navigationBarsPadding() + else Modifier + ) ) { ModalNavigationDrawer( drawerState = drawerState, @@ -812,6 +814,15 @@ fun ChatsListScreen( "rosetta", ignoreCase = true ) + val isFreddyOfficial = + accountName.equals( + "freddy", + ignoreCase = true + ) || + accountUsername.equals( + "freddy", + ignoreCase = true + ) // Avatar row with theme toggle Row( modifier = Modifier.fillMaxWidth(), @@ -925,7 +936,7 @@ fun ChatsListScreen( fontWeight = FontWeight.Bold, color = Color.White ) - if (accountVerified > 0 || isRosettaOfficial) { + if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) { Spacer( modifier = Modifier.width( @@ -935,7 +946,7 @@ fun ChatsListScreen( VerifiedBadge( verified = if (accountVerified > 0) accountVerified else 1, size = 15, - badgeTint = PrimaryBlue + badgeTint = if (isDarkTheme) Color.White else PrimaryBlue ) } } @@ -1230,7 +1241,14 @@ fun ChatsListScreen( // ═══════════════════════════════════════════════════════════ // FOOTER - Version + Update Banner // ═══════════════════════════════════════════════════════════ - Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + Column( + modifier = + Modifier.fillMaxWidth() + .then( + if (hasNativeNavigationBar) Modifier.navigationBarsPadding() + else Modifier + ) + ) { // Telegram-style update banner val curUpdate = sduUpdateState val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable || @@ -3886,7 +3904,10 @@ fun DialogItemContent( val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) || dialog.opponentUsername.equals("rosetta", ignoreCase = true) || MessageRepository.isSystemAccount(dialog.opponentKey) - if (dialog.verified > 0 || isRosettaOfficial) { + val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) || + dialog.opponentTitle.equals("freddy", ignoreCase = true) || + displayName.equals("freddy", ignoreCase = true) + if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) { Spacer(modifier = Modifier.width(4.dp)) VerifiedBadge( verified = if (dialog.verified > 0) dialog.verified else 1, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 3ccb8c5..ffe9a7a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -122,6 +122,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). * @@ -684,8 +691,17 @@ fun ChatAttachAlert( LaunchedEffect(shouldShow, state.editingItem) { if (!shouldShow || state.editingItem != null) return@LaunchedEffect val window = (view.context as? Activity)?.window ?: return@LaunchedEffect - snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) } - .collect { (alpha, fullScreen, dark) -> + snapshotFlow { + PickerSystemBarsSnapshot( + scrimAlpha = scrimAlpha, + isFullScreen = isPickerFullScreen, + isDarkTheme = isDarkTheme, + openProgress = (1f - animatedOffset).coerceIn(0f, 1f) + ) + }.collect { state -> + val alpha = state.scrimAlpha + val fullScreen = state.isFullScreen + val dark = state.isDarkTheme if (fullScreen) { window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() insetsController?.isAppearanceLightStatusBars = !dark @@ -695,8 +711,16 @@ fun ChatAttachAlert( window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0) insetsController?.isAppearanceLightStatusBars = false } - window.navigationBarColor = android.graphics.Color.TRANSPARENT - insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f + // Telegram-like: nav bar follows picker surface, not black scrim. + 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) + ) + insetsController?.isAppearanceLightNavigationBars = !dark } } @@ -825,8 +849,9 @@ fun ChatAttachAlert( } else keyboardSpacerDp // When keyboard or emoji is open, nav bar is behind — don't pad for it - val navBarDp = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0.dp - else with(density) { navigationBarInsetPx.toDp() } + val navInsetPxForSheet = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0f + else navigationBarInsetPx + val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() } Box( modifier = Modifier @@ -835,13 +860,12 @@ fun ChatAttachAlert( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null - ) { requestClose() } - .padding(bottom = navBarDp), + ) { requestClose() }, contentAlignment = Alignment.BottomCenter ) { // Sheet height stays constant — keyboard space is handled by // internal Spacer, not by shrinking the container (Telegram approach). - val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx) + val visibleSheetHeightPx = (sheetHeightPx.value + navInsetPxForSheet).coerceAtLeast(minHeightPx) val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() } val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt() val expandProgress = @@ -1096,6 +1120,9 @@ fun ChatAttachAlert( if (!coordinator.isEmojiBoxVisible) { Spacer(modifier = Modifier.height(keyboardSpacerDp)) } + if (navInsetDpForSheet > 0.dp) { + Spacer(modifier = Modifier.height(navInsetDpForSheet)) + } } // end Column // ── Floating Send Button ── @@ -1119,7 +1146,7 @@ fun ChatAttachAlert( }, modifier = Modifier .align(Alignment.BottomEnd) - .padding(bottom = bottomInputPadding) + .padding(bottom = bottomInputPadding + navInsetDpForSheet) ) } // end Box sheet container diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index afb8d36..eb54a12 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview( val cached = ImageBitmapCache.get(cacheKey) if (cached != null) { imageBitmap = cached; return@LaunchedEffect } + // 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл + if (attachment.localUri.isNotEmpty()) { + val localBitmap = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver + .openInputStream( + android.net.Uri.parse( + attachment.localUri + ) + ) + ?.use { input -> + BitmapFactory.decodeStream(input) + } + }.getOrNull() + } + if (localBitmap != null) { + imageBitmap = localBitmap + ImageBitmapCache.put(cacheKey, localBitmap) + return@LaunchedEffect + } + } + withContext(Dispatchers.IO) { // Try local file cache first try { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 7a17132..b05cc4d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -829,7 +829,7 @@ fun ImageEditorScreen( * Telegram-style toolbar - icons only, no labels */ @Composable -private fun TelegramToolbar( +internal fun TelegramToolbar( currentTool: EditorTool, showCaptionInput: Boolean, isSaving: Boolean, @@ -958,7 +958,7 @@ private fun TelegramToolButton( * Telegram-style color picker with brush size */ @Composable -private fun TelegramColorPicker( +internal fun TelegramColorPicker( selectedColor: Color, brushSize: Float, onColorSelected: (Color) -> Unit, @@ -1044,7 +1044,7 @@ private fun TelegramColorPicker( * Telegram-style rotate bar */ @Composable -private fun TelegramRotateBar( +internal fun TelegramRotateBar( onRotateLeft: () -> Unit, onRotateRight: () -> Unit, onFlipHorizontal: () -> Unit, @@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld( } /** Save edited image synchronously (with all editor changes). */ -private suspend fun saveEditedImageSync( +internal suspend fun saveEditedImageSync( context: Context, photoEditor: PhotoEditor?, photoEditorView: PhotoEditorView?, @@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 6109f30..f7ed7e7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -82,6 +82,13 @@ import kotlin.math.roundToInt private const val TAG = "MediaPickerBottomSheet" private const val ALL_MEDIA_ALBUM_ID = 0L +private data class PickerSystemBarsSnapshot( + val scrimAlpha: Float, + val isFullScreen: Boolean, + val isDarkTheme: Boolean, + val openProgress: Float +) + /** * Media item from gallery */ @@ -572,8 +579,17 @@ fun MediaPickerBottomSheet( if (!shouldShow || editingItem != null) return@LaunchedEffect val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect - snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) } - .collect { (alpha, fullScreen, dark) -> + snapshotFlow { + PickerSystemBarsSnapshot( + scrimAlpha = scrimAlpha, + isFullScreen = isPickerFullScreen, + isDarkTheme = isDarkTheme, + openProgress = (1f - animatedOffset).coerceIn(0f, 1f) + ) + }.collect { state -> + val alpha = state.scrimAlpha + val fullScreen = state.isFullScreen + val dark = state.isDarkTheme if (fullScreen) { // Full screen: status bar = picker background, seamless window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() @@ -585,11 +601,16 @@ fun MediaPickerBottomSheet( ) insetsController?.isAppearanceLightStatusBars = false } - // Navigation bar always follows scrim + // Telegram-like: nav bar follows picker surface, not scrim. + val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() + val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255) 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 + insetsController?.isAppearanceLightNavigationBars = !dark } } @@ -628,7 +649,8 @@ fun MediaPickerBottomSheet( (imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f) val appliedKeyboardInsetPx = if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f - val navBarDp = with(density) { navigationBarInsetPx.toDp() } + val navInsetPxForSheet = if (appliedKeyboardInsetPx > 0f) 0f else navigationBarInsetPx + val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() } // Полноэкранный контейнер с мягким затемнением // background BEFORE padding — scrim covers area behind keyboard too @@ -639,14 +661,14 @@ fun MediaPickerBottomSheet( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null - ) { requestClose() } - .padding(bottom = navBarDp), + ) { requestClose() }, contentAlignment = Alignment.BottomCenter ) { // Subtract keyboard from sheet height so it fits in the resized viewport. // The grid (weight=1f) shrinks; caption bar stays at the bottom edge. val visibleSheetHeightPx = - (sheetHeightPx.value - appliedKeyboardInsetPx).coerceAtLeast(minHeightPx) + (sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet) + .coerceAtLeast(minHeightPx) val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() } val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt() val expandProgress = @@ -1151,6 +1173,9 @@ fun MediaPickerBottomSheet( } } } + if (navInsetDpForSheet > 0.dp) { + Spacer(modifier = Modifier.height(navInsetDpForSheet)) + } } // end Column // ═══════════════════════════════════════════════════════ @@ -1169,7 +1194,7 @@ fun MediaPickerBottomSheet( Box( modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 14.dp, bottom = 8.dp) + .padding(end = 14.dp, bottom = 8.dp + navInsetDpForSheet) .graphicsLayer { scaleX = sendScale scaleY = sendScale diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt index a170bfa..22fbdac 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt @@ -1,46 +1,97 @@ package com.rosetta.messenger.ui.chats.components +import android.app.Activity +import android.content.Context import android.graphics.Color as AndroidColor import android.graphics.drawable.ColorDrawable +import android.net.Uri import android.os.Build import android.view.View import android.view.Window import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.ImageView import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat -import coil.compose.AsyncImage +import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition +import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator +import com.rosetta.messenger.ui.components.AppleEmojiEditTextView +import com.rosetta.messenger.ui.components.AppleEmojiTextField +import com.rosetta.messenger.ui.components.KeyboardHeightProvider +import com.rosetta.messenger.ui.components.OptimizedEmojiPicker +import com.rosetta.messenger.ui.icons.TelegramIcons +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import com.yalantis.ucrop.UCrop +import ja.burhanrashid52.photoeditor.PhotoEditor +import ja.burhanrashid52.photoeditor.PhotoEditorView +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch private val ViewerExpandEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) @@ -104,9 +155,14 @@ private fun setupFullscreenWindow(window: Window?) { @Composable fun SimpleFullscreenPhotoViewer( - imageUri: android.net.Uri, + imageUri: Uri, onDismiss: () -> Unit, - sourceThumbnail: ThumbnailPosition? = null + sourceThumbnail: ThumbnailPosition? = null, + showCaptionInput: Boolean = false, + caption: String = "", + onCaptionChange: ((String) -> Unit)? = null, + onSend: ((Uri, String) -> Unit)? = null, + isDarkTheme: Boolean = true ) { Dialog( onDismissRequest = onDismiss, @@ -126,41 +182,117 @@ fun SimpleFullscreenPhotoViewer( SimpleFullscreenPhotoContent( imageUri = imageUri, onDismiss = onDismiss, - sourceThumbnail = sourceThumbnail + sourceThumbnail = sourceThumbnail, + showCaptionInput = showCaptionInput, + caption = caption, + onCaptionChange = onCaptionChange, + onSend = onSend, + isDarkTheme = isDarkTheme ) } } @Composable fun SimpleFullscreenPhotoOverlay( - imageUri: android.net.Uri, + imageUri: Uri, onDismiss: () -> Unit, sourceThumbnail: ThumbnailPosition? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showCaptionInput: Boolean = false, + caption: String = "", + onCaptionChange: ((String) -> Unit)? = null, + onSend: ((Uri, String) -> Unit)? = null, + isDarkTheme: Boolean = true ) { SimpleFullscreenPhotoContent( imageUri = imageUri, onDismiss = onDismiss, sourceThumbnail = sourceThumbnail, - modifier = modifier + modifier = modifier, + showCaptionInput = showCaptionInput, + caption = caption, + onCaptionChange = onCaptionChange, + onSend = onSend, + isDarkTheme = isDarkTheme ) } @Composable private fun SimpleFullscreenPhotoContent( - imageUri: android.net.Uri, + imageUri: Uri, onDismiss: () -> Unit, sourceThumbnail: ThumbnailPosition? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showCaptionInput: Boolean = false, + caption: String = "", + onCaptionChange: ((String) -> Unit)? = null, + onSend: ((Uri, String) -> Unit)? = null, + isDarkTheme: Boolean = true ) { + val context = LocalContext.current + val view = LocalView.current + val focusManager = LocalFocusManager.current + val density = LocalDensity.current val scope = rememberCoroutineScope() + var isClosing by remember { mutableStateOf(false) } var screenSize by remember { mutableStateOf(IntSize.Zero) } + var showEmojiPicker by remember { mutableStateOf(false) } + var editTextView by remember { mutableStateOf(null) } + var lastToggleTime by remember { mutableLongStateOf(0L) } + var isKeyboardVisible by remember { mutableStateOf(false) } + var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } + var localCaption by remember(imageUri) { mutableStateOf("") } + var currentImageUri by remember(imageUri) { mutableStateOf(imageUri) } + var currentTool by remember { mutableStateOf(EditorTool.NONE) } + var selectedColor by remember { mutableStateOf(Color.White) } + var brushSize by remember { mutableStateOf(12f) } + var showColorPicker by remember { mutableStateOf(false) } + var isEraserActive by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } + var photoEditor by remember { mutableStateOf(null) } + var photoEditorView by remember { mutableStateOf(null) } + var hasDrawingEdits by remember { mutableStateOf(false) } + var rotationAngle by remember { mutableStateOf(0f) } + var isFlippedHorizontally by remember { mutableStateOf(false) } + var isFlippedVertically by remember { mutableStateOf(false) } + val progress = remember(imageUri, sourceThumbnail) { Animatable(if (sourceThumbnail != null) 0f else 1f) } + val coordinator = rememberKeyboardTransitionCoordinator() + val imeInsets = WindowInsets.ime + val toggleCooldownMs = 500L + + val cropLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + UCrop.getOutput(data)?.let { croppedUri -> + currentImageUri = croppedUri + rotationAngle = 0f + isFlippedHorizontally = false + isFlippedVertically = false + currentTool = EditorTool.NONE + showColorPicker = false + isEraserActive = false + } + } + } + } + + val captionText = if (onCaptionChange != null) caption else localCaption + val updateCaption: (String) -> Unit = { value -> + if (onCaptionChange != null) { + onCaptionChange(value) + } else { + localCaption = value + } + } LaunchedEffect(imageUri, sourceThumbnail) { + localCaption = caption if (progress.value < 1f) { progress.animateTo( targetValue = 1f, @@ -169,9 +301,46 @@ private fun SimpleFullscreenPhotoContent( } } + LaunchedEffect(showCaptionInput) { + if (!showCaptionInput) return@LaunchedEffect + snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight -> + isKeyboardVisible = currentImeHeight > 50.dp + coordinator.updateKeyboardHeight(currentImeHeight) + if (currentImeHeight > 100.dp) { + coordinator.syncHeights() + lastStableKeyboardHeight = currentImeHeight + } + } + } + + LaunchedEffect(showCaptionInput) { + if (showCaptionInput) { + KeyboardHeightProvider.getSavedKeyboardHeight(context) + } + } + + LaunchedEffect(showCaptionInput, isKeyboardVisible, showEmojiPicker, lastStableKeyboardHeight) { + if (!showCaptionInput) return@LaunchedEffect + if (isKeyboardVisible && !showEmojiPicker) { + delay(350) + if (isKeyboardVisible && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) { + val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() } + KeyboardHeightProvider.saveKeyboardHeight(context, heightPx) + } + } + } + + fun hideKeyboard() { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + } + fun closeViewer() { if (isClosing) return isClosing = true + showEmojiPicker = false + hideKeyboard() + focusManager.clearFocus(force = true) scope.launch { progress.animateTo( targetValue = 0f, @@ -181,6 +350,34 @@ private fun SimpleFullscreenPhotoContent( } } + fun toggleEmojiPicker() { + val now = System.currentTimeMillis() + if (now - lastToggleTime < toggleCooldownMs) { + return + } + lastToggleTime = now + + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (coordinator.isEmojiVisible) { + coordinator.requestShowKeyboard( + showKeyboard = { + editTextView?.let { editText -> + editText.requestFocus() + imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + } + }, + hideEmoji = { showEmojiPicker = false } + ) + } else { + coordinator.requestShowEmoji( + hideKeyboard = { + imm.hideSoftInputFromWindow(view.windowToken, 0) + }, + showEmoji = { showEmojiPicker = true } + ) + } + } + BackHandler { closeViewer() } val transform by remember(sourceThumbnail, screenSize, progress.value) { @@ -219,17 +416,48 @@ private fun SimpleFullscreenPhotoContent( } } + val tapToDismissModifier = + if (!showCaptionInput) { + Modifier.pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) } + } else { + Modifier + } + Box( modifier = modifier.fillMaxSize() .onSizeChanged { screenSize = it } .background(Color.Black) - .pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) }, + .then(tapToDismissModifier), contentAlignment = Alignment.Center ) { - AsyncImage( - model = imageUri, - contentDescription = "Photo", + AndroidView( + factory = { ctx -> + PhotoEditorView(ctx).apply { + photoEditorView = this + setPadding(0, 0, 0, 0) + setBackgroundColor(android.graphics.Color.BLACK) + source.apply { + scaleType = ImageView.ScaleType.CENTER_CROP + adjustViewBounds = false + setPadding(0, 0, 0, 0) + setImageURI(currentImageUri) + } + photoEditor = PhotoEditor.Builder(ctx, this) + .setPinchTextScalable(true) + .setClipSourceImage(true) + .build() + } + }, + update = { editorView -> + if (editorView.source.tag != currentImageUri) { + editorView.source.setImageURI(currentImageUri) + editorView.source.tag = currentImageUri + } + editorView.source.rotation = rotationAngle + editorView.source.scaleX = if (isFlippedHorizontally) -1f else 1f + editorView.source.scaleY = if (isFlippedVertically) -1f else 1f + }, modifier = Modifier.fillMaxSize() .graphicsLayer { @@ -244,8 +472,297 @@ private fun SimpleFullscreenPhotoContent( } else { Modifier } - ), - contentScale = ContentScale.Crop + ) ) + + if (showCaptionInput) { + Box( + modifier = + Modifier.fillMaxWidth() + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + colors = + listOf( + Color.Black.copy(alpha = 0.55f), + Color.Transparent + ) + ) + ) + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 8.dp) + ) { + IconButton( + onClick = { closeViewer() }, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Icon( + painter = TelegramIcons.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW && showColorPicker, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(bottom = 132.dp) + ) { + TelegramColorPicker( + selectedColor = selectedColor, + brushSize = brushSize, + onColorSelected = { color -> + selectedColor = color + photoEditor?.brushColor = color.toArgb() + }, + onBrushSizeChanged = { size -> + brushSize = size + photoEditor?.brushSize = size + } + ) + } + + AnimatedVisibility( + visible = currentTool == EditorTool.ROTATE, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(bottom = 132.dp) + ) { + TelegramRotateBar( + onRotateLeft = { rotationAngle = (rotationAngle - 90f) % 360f }, + onRotateRight = { rotationAngle = (rotationAngle + 90f) % 360f }, + onFlipHorizontal = { isFlippedHorizontally = !isFlippedHorizontally }, + onFlipVertical = { isFlippedVertically = !isFlippedVertically } + ) + } + + val shouldUseImePadding = !coordinator.isEmojiBoxVisible + val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible + + Column( + modifier = + Modifier.fillMaxWidth() + .align(Alignment.BottomCenter) + .then(if (shouldUseImePadding) Modifier.imePadding() else Modifier) + ) { + AnimatedVisibility( + visible = !isKeyboardVisible && !showEmojiPicker && !coordinator.isEmojiBoxVisible, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it } + ) { + Box( + modifier = + Modifier.fillMaxWidth() + .background( + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.6f) + ) + ) + ) + ) { + TelegramToolbar( + currentTool = currentTool, + showCaptionInput = true, + isSaving = isSaving, + isEraserActive = isEraserActive, + onCropClick = { + currentTool = EditorTool.NONE + showColorPicker = false + isEraserActive = false + photoEditor?.setBrushDrawingMode(false) + launchCrop(context, currentImageUri, cropLauncher) + }, + onRotateClick = { + currentTool = + if (currentTool == EditorTool.ROTATE) EditorTool.NONE + else EditorTool.ROTATE + showColorPicker = false + isEraserActive = false + photoEditor?.setBrushDrawingMode(false) + }, + onDrawClick = { + if (currentTool == EditorTool.DRAW) { + if (isEraserActive) { + isEraserActive = false + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + } else { + showColorPicker = !showColorPicker + } + } else { + currentTool = EditorTool.DRAW + hasDrawingEdits = true + isEraserActive = false + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + showColorPicker = true + } + }, + onEraserClick = { + isEraserActive = !isEraserActive + if (isEraserActive) { + photoEditor?.brushEraser() + } else { + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + } + }, + onDrawDoneClick = { + currentTool = EditorTool.NONE + showColorPicker = false + isEraserActive = false + photoEditor?.setBrushDrawingMode(false) + }, + onDoneClick = {} + ) + } + } + + Box( + modifier = + Modifier.fillMaxWidth() + .background(Color.Black.copy(alpha = 0.75f)) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + bottom = + if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 10.dp + else 16.dp + ) + .then( + if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() + else Modifier + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + IconButton( + onClick = { toggleEmojiPicker() }, + modifier = Modifier.size(32.dp) + ) { + Crossfade( + targetState = showEmojiPicker, + animationSpec = tween(150), + label = "simpleViewerEmojiToggle" + ) { isEmoji -> + Icon( + painter = if (isEmoji) TelegramIcons.Keyboard else TelegramIcons.Smile, + contentDescription = if (isEmoji) "Keyboard" else "Emoji", + tint = Color.White.copy(alpha = 0.72f), + modifier = Modifier.size(26.dp) + ) + } + } + + Box( + modifier = + Modifier.weight(1f) + .heightIn(min = 24.dp, max = 100.dp) + ) { + AppleEmojiTextField( + value = captionText, + onValueChange = updateCaption, + textColor = Color.White, + textSize = 16f, + hint = "Add a caption...", + hintColor = Color.White.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + requestFocus = false, + onViewCreated = { textView -> editTextView = textView }, + onFocusChanged = { hasFocus -> + if (hasFocus && showEmojiPicker) { + toggleEmojiPicker() + } + } + ) + } + + Box( + modifier = + Modifier.size(44.dp) + .shadow( + elevation = 4.dp, + shape = CircleShape, + clip = false + ) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable(enabled = !isSaving) { + if (isSaving || isClosing) return@clickable + showEmojiPicker = false + hideKeyboard() + focusManager.clearFocus(force = true) + scope.launch { + isSaving = true + val savedUri = + saveEditedImageSync( + context = context, + photoEditor = photoEditor, + photoEditorView = photoEditorView, + imageUri = currentImageUri, + hasDrawingEdits = hasDrawingEdits + ) + isSaving = false + val finalUri = savedUri ?: currentImageUri + if (onSend != null) { + onSend(finalUri, captionText) + } else { + closeViewer() + } + } + }, + contentAlignment = Alignment.Center + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + painter = TelegramIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = + Modifier.size(24.dp) + .offset(x = 1.dp) + ) + } + } + } + } + + AnimatedKeyboardTransition( + coordinator = coordinator, + showEmojiPicker = showEmojiPicker + ) { + OptimizedEmojiPicker( + isVisible = true, + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> updateCaption(captionText + emoji) }, + onClose = { toggleEmojiPicker() }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 030d0fc..c805e98 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -104,7 +104,8 @@ fun MessageInputBar( mentionCandidates: List = emptyList(), avatarRepository: AvatarRepository? = null, inputFocusTrigger: Int = 0, - suppressKeyboard: Boolean = false + suppressKeyboard: Boolean = false, + hasNativeNavigationBar: Boolean = true ) { val hasReply = replyMessages.isNotEmpty() val liveReplyMessages = @@ -367,7 +368,10 @@ fun MessageInputBar( .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 16.dp) .padding(bottom = 20.dp) - .navigationBarsPadding(), + .then( + if (hasNativeNavigationBar) Modifier.navigationBarsPadding() + else Modifier + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { @@ -398,7 +402,10 @@ fun MessageInputBar( ) ) - val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible + val shouldAddNavBarPadding = + hasNativeNavigationBar && + !isKeyboardVisible && + !coordinator.isEmojiBoxVisible Column( modifier = Modifier diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index b438d5c..281b285 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -45,8 +45,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import com.airbnb.lottie.compose.* import com.rosetta.messenger.R import com.rosetta.messenger.ui.theme.* @@ -124,8 +122,11 @@ fun OnboardingScreen( // Animate navigation bar color starting at 80% of wave animation val view = LocalView.current + val isGestureNavigation = remember(view.context) { + NavigationModeUtils.isGestureNavigation(view.context) + } LaunchedEffect(isTransitioning, transitionProgress) { - if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { + if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { val window = (view.context as android.app.Activity).window // Map 0.8-1.0 to 0-1 for smooth interpolation val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) @@ -163,7 +164,10 @@ fun OnboardingScreen( // Navigation bar: показываем только если есть нативные кнопки NavigationModeUtils.applyNavigationBarVisibility( - insetsController, view.context, isDarkTheme + window = window, + insetsController = insetsController, + context = view.context, + isDarkTheme = isDarkTheme ) } } @@ -173,10 +177,12 @@ fun OnboardingScreen( if (!view.isInEditMode) { val window = (view.context as android.app.Activity).window val insetsController = WindowCompat.getInsetsController(window, view) - window.navigationBarColor = - if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() - insetsController.show(WindowInsetsCompat.Type.navigationBars()) - insetsController.isAppearanceLightNavigationBars = !isDarkTheme + NavigationModeUtils.applyNavigationBarVisibility( + window = window, + insetsController = insetsController, + context = view.context, + isDarkTheme = isDarkTheme + ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index de5afd4..f892ef2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader( val isRosettaOfficial = name.equals("Rosetta", ignoreCase = true) || username.equals("rosetta", ignoreCase = true) + val isFreddyOfficial = + name.equals("freddy", ignoreCase = true) || + username.equals("freddy", ignoreCase = true) // ═══════════════════════════════════════════════════════════ // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой @@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader( textAlign = TextAlign.Center ) - if (verified > 0 || isRosettaOfficial) { + if (verified > 0 || isRosettaOfficial || isFreddyOfficial) { Spacer(modifier = Modifier.width(4.dp)) VerifiedBadge( verified = if (verified > 0) verified else 1, diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index a0be4c2..9c58828 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader( val isRosettaOfficial = name.equals("Rosetta", ignoreCase = true) || username.equals("rosetta", ignoreCase = true) + val isFreddyOfficial = + name.equals("freddy", ignoreCase = true) || + username.equals("freddy", ignoreCase = true) Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { // Expansion fraction — computed early so gradient can fade during expansion @@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader( modifier = Modifier.widthIn(max = 220.dp), textAlign = TextAlign.Center ) - if (verified > 0 || isRosettaOfficial) { + if (verified > 0 || isRosettaOfficial || isFreddyOfficial) { Spacer(modifier = Modifier.width(4.dp)) VerifiedBadge( verified = if (verified > 0) verified else 2, diff --git a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt index eab98e9..e88589b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt @@ -68,15 +68,21 @@ fun RosettaAndroidTheme( val view = LocalView.current val context = LocalContext.current if (!view.isInEditMode) { - SideEffect { + DisposableEffect(darkTheme, view, context) { val window = (view.context as android.app.Activity).window val insetsController = WindowCompat.getInsetsController(window, view) // Make status bar transparent for wave animation overlay window.statusBarColor = AndroidColor.TRANSPARENT // Note: isAppearanceLightStatusBars is managed per-screen, not globally - // Navigation bar: показываем только если есть нативные кнопки - NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme) + NavigationModeUtils.applyNavigationBarVisibility( + window = window, + insetsController = insetsController, + context = context, + isDarkTheme = darkTheme + ) + + onDispose { } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt index d4f12b7..4e65535 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt @@ -1,6 +1,10 @@ package com.rosetta.messenger.ui.utils import android.content.Context +import android.graphics.Color +import android.os.Build +import android.view.View +import android.view.Window import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -58,12 +62,42 @@ object NavigationModeUtils { * Показывает navigation bar на всех устройствах. */ fun applyNavigationBarVisibility( + window: Window, insetsController: WindowInsetsControllerCompat, context: Context, isDarkTheme: Boolean ) { + val gestureNavigation = isGestureNavigation(context) + val decorView = window.decorView + insetsController.show(WindowInsetsCompat.Type.navigationBars()) - insetsController.isAppearanceLightNavigationBars = !isDarkTheme + if (gestureNavigation) { + // In gesture mode we keep a transparent nav bar and fixed white handle. + // This avoids the "jump" effect during theme switching. + window.navigationBarColor = Color.TRANSPARENT + insetsController.isAppearanceLightNavigationBars = false + val newFlags = + decorView.systemUiVisibility or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + if (newFlags != decorView.systemUiVisibility) { + decorView.systemUiVisibility = newFlags + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + } else { + window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() + insetsController.isAppearanceLightNavigationBars = !isDarkTheme + val newFlags = + decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv() + if (newFlags != decorView.systemUiVisibility) { + decorView.systemUiVisibility = newFlags + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = true + } + } } }