Compare commits

...

2 Commits

14 changed files with 1287 additions and 242 deletions

View File

@@ -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
@@ -162,6 +163,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 +294,9 @@ 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("") }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме // 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
if (!view.isInEditMode) { if (!view.isInEditMode) {
@@ -364,7 +371,8 @@ fun ChatDetailScreen(
showEmojiPicker, showEmojiPicker,
pendingCameraPhotoUri, pendingCameraPhotoUri,
pendingGalleryImages, pendingGalleryImages,
showInAppCamera showInAppCamera,
simplePickerPreviewUri
) { ) {
derivedStateOf { derivedStateOf {
showImageViewer || showImageViewer ||
@@ -372,7 +380,8 @@ fun ChatDetailScreen(
showEmojiPicker || showEmojiPicker ||
pendingCameraPhotoUri != null || pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() || pendingGalleryImages.isNotEmpty() ||
showInAppCamera showInAppCamera ||
simplePickerPreviewUri != null
} }
} }
@@ -380,6 +389,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) }
} }
@@ -1801,7 +1817,10 @@ fun ChatDetailScreen(
bottom = bottom =
16.dp 16.dp
) )
.navigationBarsPadding() .then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
.graphicsLayer { .graphicsLayer {
scaleX = scaleX =
buttonScale buttonScale
@@ -1975,14 +1994,7 @@ fun ChatDetailScreen(
it.type != it.type !=
AttachmentType AttachmentType
.MESSAGES .MESSAGES
} }
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
) )
} }
} else { } else {
@@ -2014,14 +2026,7 @@ fun ChatDetailScreen(
it.type != it.type !=
AttachmentType AttachmentType
.MESSAGES .MESSAGES
} }
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
)) ))
} }
} }
@@ -2168,7 +2173,9 @@ fun ChatDetailScreen(
inputFocusTrigger = inputFocusTrigger =
inputFocusTrigger, inputFocusTrigger,
suppressKeyboard = suppressKeyboard =
showInAppCamera showInAppCamera,
hasNativeNavigationBar =
hasNativeNavigationBar
) )
} }
} }
@@ -2513,6 +2520,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 +2563,9 @@ fun ChatDetailScreen(
) )
}, },
onClick = { onClick = {
if (simplePickerPreviewUri != null) {
return@MessageBubble
}
if (shouldIgnoreTapAfterLongPress( if (shouldIgnoreTapAfterLongPress(
selectionKey selectionKey
) )
@@ -3005,7 +3018,16 @@ fun ChatDetailScreen(
onAvatarClick = { onAvatarClick = {
viewModel.sendAvatarMessage() viewModel.sendAvatarMessage()
}, },
recipientName = user.title recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb ->
hideInputOverlays()
showMediaPicker = false
showContextMenu = false
contextMenuMessage = null
simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri
}
) )
} else { } else {
MediaPickerBottomSheet( MediaPickerBottomSheet(
@@ -3049,7 +3071,16 @@ fun ChatDetailScreen(
onAvatarClick = { onAvatarClick = {
viewModel.sendAvatarMessage() viewModel.sendAvatarMessage()
}, },
recipientName = user.title recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb ->
hideInputOverlays()
showMediaPicker = false
showContextMenu = false
contextMenuMessage = null
simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri
}
) )
} }
} // Закрытие Box wrapper для Scaffold content } // Закрытие Box wrapper для Scaffold content
@@ -3314,6 +3345,32 @@ fun ChatDetailScreen(
} // Закрытие Scaffold content lambda } // Закрытие Scaffold content lambda
simplePickerPreviewUri?.let { previewUri ->
SimpleFullscreenPhotoOverlay(
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++
}
)
}
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold // <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
if (showImageViewer && imageViewerImages.isNotEmpty()) { if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen( ImageViewerScreen(

View File

@@ -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
) )
) )
} }
@@ -2566,12 +2570,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 +2600,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 +2769,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(
id = replyAttachmentId, MessageAttachment(
blob = encryptedReplyBlob, id = replyAttachmentId,
type = AttachmentType.MESSAGES, blob = encryptedReplyBlob,
preview = "" type = AttachmentType.MESSAGES,
)) preview = ""
)
)
// Отправляем пакет // Отправляем пакет
val packet = PacketMessage().apply { val packet = PacketMessage().apply {
@@ -2683,58 +2799,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("id", att.id) put(
put("type", att.type.value) JSONObject().apply {
put("preview", att.preview) put("id", att.id)
put("width", att.width) put("type", att.type.value)
put("height", att.height) put("preview", att.preview)
put("blob", when (att.type) { put("width", att.width)
AttachmentType.MESSAGES -> replyBlobForDatabase put("height", att.height)
else -> "" put(
}) "blob",
}) when (att.type) {
} AttachmentType.MESSAGES ->
}.toString() replyBlobForDatabase
else -> ""
}
)
}
)
}
}
.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 {
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
} }
} catch (e: Exception) { } refreshTargetDialog()
} catch (e: Exception) {
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
}
} }
} }

View File

@@ -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,

View File

@@ -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
@@ -121,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). * Telegram-style attach alert (media picker bottom sheet).
* *
@@ -146,6 +154,7 @@ fun ChatAttachAlert(
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
maxSelection: Int = 10, maxSelection: Int = 10,
recipientName: String? = null, recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null,
viewModel: AttachAlertViewModel = viewModel() viewModel: AttachAlertViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -679,11 +688,20 @@ fun ChatAttachAlert(
} }
} }
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 +711,16 @@ 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
} }
window.navigationBarColor = android.graphics.Color.TRANSPARENT // Telegram-like: nav bar follows picker surface, not black scrim.
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f 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
} }
} }
@@ -710,7 +736,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 +849,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 +860,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 +1002,13 @@ fun ChatAttachAlert(
}, },
onItemClick = { item, position -> onItemClick = { item, position ->
if (!item.isVideo) { if (!item.isVideo) {
thumbnailPosition = position hideKeyboard()
viewModel.setEditingItem(item) if (onPhotoPreviewRequested != null) {
onPhotoPreviewRequested(item.uri, position)
} else {
thumbnailPosition = position
viewModel.setEditingItem(item)
}
} else { } else {
viewModel.toggleSelection(item.id, maxSelection) viewModel.toggleSelection(item.id, maxSelection)
} }
@@ -1089,6 +1120,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 +1146,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 +1207,14 @@ fun ChatAttachAlert(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
state.editingItem?.let { item -> state.editingItem?.let { item ->
ImageEditorScreen( SimpleFullscreenPhotoViewer(
imageUri = item.uri, imageUri = item.uri,
sourceThumbnail = thumbnailPosition,
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
) )
} }

View File

@@ -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 {

View File

@@ -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>

View File

@@ -82,6 +82,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,7 +132,8 @@ 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?) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -567,12 +575,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 +601,21 @@ fun MediaPickerBottomSheet(
) )
insetsController?.isAppearanceLightStatusBars = false 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( 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
} }
} }
// Используем Popup для показа поверх клавиатуры // Используем Popup для показа поверх клавиатуры
if (shouldShow) { if (shouldShow && editingItem == null) {
// BackHandler для закрытия по back // BackHandler для закрытия по back
BackHandler { BackHandler {
if (isExpanded) { if (isExpanded) {
@@ -627,7 +649,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 +661,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 +1009,13 @@ fun MediaPickerBottomSheet(
}, },
onItemClick = { item, position -> onItemClick = { item, position ->
if (!item.isVideo) { if (!item.isVideo) {
thumbnailPosition = position hideKeyboard()
editingItem = item if (onPhotoPreviewRequested != null) {
onPhotoPreviewRequested(item.uri, position)
} else {
thumbnailPosition = position
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 +1173,9 @@ fun MediaPickerBottomSheet(
} }
} }
} }
if (navInsetDpForSheet > 0.dp) {
Spacer(modifier = Modifier.height(navInsetDpForSheet))
}
} // end Column } // end Column
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
@@ -1163,7 +1194,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 +1310,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
) )
} }

View File

@@ -0,0 +1,768 @@
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.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 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)
private data class SimpleViewerTransform(
val scaleX: Float,
val scaleY: Float,
val translationX: Float,
val translationY: Float,
val cornerRadiusDp: Float
)
private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
return start + (stop - start) * fraction
}
private fun setupFullscreenWindow(window: Window?) {
window ?: return
WindowCompat.setDecorFitsSystemWindows(window, false)
window.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT
)
window.setBackgroundDrawable(ColorDrawable(AndroidColor.TRANSPARENT))
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.decorView.setPadding(0, 0, 0, 0)
val attrs = window.attributes
attrs.width = WindowManager.LayoutParams.MATCH_PARENT
attrs.height = WindowManager.LayoutParams.MATCH_PARENT
attrs.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes = attrs
val decorView = window.decorView
val telegramLikeFlags =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (decorView.systemUiVisibility != telegramLikeFlags) {
decorView.systemUiVisibility = telegramLikeFlags
}
window.statusBarColor = AndroidColor.TRANSPARENT
window.navigationBarColor = AndroidColor.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isStatusBarContrastEnforced = false
window.isNavigationBarContrastEnforced = false
}
val controller = WindowCompat.getInsetsController(window, decorView)
controller.isAppearanceLightStatusBars = false
controller.isAppearanceLightNavigationBars = false
controller.show(WindowInsetsCompat.Type.statusBars())
controller.show(WindowInsetsCompat.Type.navigationBars())
window.setWindowAnimations(0)
}
@Composable
fun SimpleFullscreenPhotoViewer(
imageUri: Uri,
onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null,
showCaptionInput: Boolean = false,
caption: String = "",
onCaptionChange: ((String) -> Unit)? = null,
onSend: ((Uri, String) -> Unit)? = null,
isDarkTheme: Boolean = true
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
val view = LocalView.current
SideEffect {
val dialogWindow = (view.parent as? DialogWindowProvider)?.window
setupFullscreenWindow(dialogWindow)
}
SimpleFullscreenPhotoContent(
imageUri = imageUri,
onDismiss = onDismiss,
sourceThumbnail = sourceThumbnail,
showCaptionInput = showCaptionInput,
caption = caption,
onCaptionChange = onCaptionChange,
onSend = onSend,
isDarkTheme = isDarkTheme
)
}
}
@Composable
fun SimpleFullscreenPhotoOverlay(
imageUri: Uri,
onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null,
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,
showCaptionInput = showCaptionInput,
caption = caption,
onCaptionChange = onCaptionChange,
onSend = onSend,
isDarkTheme = isDarkTheme
)
}
@Composable
private fun SimpleFullscreenPhotoContent(
imageUri: Uri,
onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null,
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<AppleEmojiEditTextView?>(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<PhotoEditor?>(null) }
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(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,
animationSpec = tween(durationMillis = 230, easing = ViewerExpandEasing)
)
}
}
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,
animationSpec = tween(durationMillis = 210, easing = ViewerExpandEasing)
)
onDismiss()
}
}
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) {
derivedStateOf {
val p = progress.value
if (sourceThumbnail != null && screenSize != IntSize.Zero) {
val screenW = screenSize.width.toFloat().coerceAtLeast(1f)
val screenH = screenSize.height.toFloat().coerceAtLeast(1f)
val srcW = sourceThumbnail.width.coerceAtLeast(1f)
val srcH = sourceThumbnail.height.coerceAtLeast(1f)
val sourceScaleX = srcW / screenW
val sourceScaleY = srcH / screenH
val targetCenterX = screenW / 2f
val targetCenterY = screenH / 2f
val sourceCenterX = sourceThumbnail.x + srcW / 2f
val sourceCenterY = sourceThumbnail.y + srcH / 2f
SimpleViewerTransform(
scaleX = lerpFloat(sourceScaleX, 1f, p),
scaleY = lerpFloat(sourceScaleY, 1f, p),
translationX = lerpFloat(sourceCenterX - targetCenterX, 0f, p),
translationY = lerpFloat(sourceCenterY - targetCenterY, 0f, p),
cornerRadiusDp = lerpFloat(sourceThumbnail.cornerRadius, 0f, p)
)
} else {
SimpleViewerTransform(
scaleX = lerpFloat(0.94f, 1f, p),
scaleY = lerpFloat(0.94f, 1f, p),
translationX = 0f,
translationY = 0f,
cornerRadiusDp = 0f
)
}
}
}
val tapToDismissModifier =
if (!showCaptionInput) {
Modifier.pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) }
} else {
Modifier
}
Box(
modifier =
modifier.fillMaxSize()
.onSizeChanged { screenSize = it }
.background(Color.Black)
.then(tapToDismissModifier),
contentAlignment = Alignment.Center
) {
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 {
scaleX = transform.scaleX
scaleY = transform.scaleY
translationX = transform.translationX
translationY = transform.translationY
}
.then(
if (transform.cornerRadiusDp > 0f) {
Modifier.clip(RoundedCornerShape(transform.cornerRadiusDp.dp))
} else {
Modifier
}
)
)
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()
)
}
}
}
}
}

View File

@@ -104,7 +104,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 =
@@ -367,7 +368,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 +402,10 @@ fun MessageInputBar(
) )
) )
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible val shouldAddNavBarPadding =
hasNativeNavigationBar &&
!isKeyboardVisible &&
!coordinator.isEmojiBoxVisible
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -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
)
} }
} }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 { }
} }
} }

View File

@@ -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())
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
}
}
} }
} }