Доработан fullscreen фото-экран: добавлены инструменты редактирования, исправлены оверлеи и ускорена пересылка фото через optimistic UI
This commit is contained in:
@@ -296,6 +296,7 @@ fun ChatDetailScreen(
|
|||||||
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
|
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
|
||||||
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
|
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
|
||||||
|
var simplePickerPreviewCaption by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
@@ -388,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) }
|
||||||
}
|
}
|
||||||
@@ -1986,14 +1994,7 @@ fun ChatDetailScreen(
|
|||||||
it.type !=
|
it.type !=
|
||||||
AttachmentType
|
AttachmentType
|
||||||
.MESSAGES
|
.MESSAGES
|
||||||
}
|
}
|
||||||
.map {
|
|
||||||
attachment ->
|
|
||||||
attachment.copy(
|
|
||||||
localUri =
|
|
||||||
""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2025,14 +2026,7 @@ fun ChatDetailScreen(
|
|||||||
it.type !=
|
it.type !=
|
||||||
AttachmentType
|
AttachmentType
|
||||||
.MESSAGES
|
.MESSAGES
|
||||||
}
|
}
|
||||||
.map {
|
|
||||||
attachment ->
|
|
||||||
attachment.copy(
|
|
||||||
localUri =
|
|
||||||
""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2526,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 =
|
||||||
@@ -2566,6 +2563,9 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (simplePickerPreviewUri != null) {
|
||||||
|
return@MessageBubble
|
||||||
|
}
|
||||||
if (shouldIgnoreTapAfterLongPress(
|
if (shouldIgnoreTapAfterLongPress(
|
||||||
selectionKey
|
selectionKey
|
||||||
)
|
)
|
||||||
@@ -3022,7 +3022,10 @@ fun ChatDetailScreen(
|
|||||||
onPhotoPreviewRequested = { uri, sourceThumb ->
|
onPhotoPreviewRequested = { uri, sourceThumb ->
|
||||||
hideInputOverlays()
|
hideInputOverlays()
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
|
showContextMenu = false
|
||||||
|
contextMenuMessage = null
|
||||||
simplePickerPreviewSourceThumb = sourceThumb
|
simplePickerPreviewSourceThumb = sourceThumb
|
||||||
|
simplePickerPreviewCaption = ""
|
||||||
simplePickerPreviewUri = uri
|
simplePickerPreviewUri = uri
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3072,7 +3075,10 @@ fun ChatDetailScreen(
|
|||||||
onPhotoPreviewRequested = { uri, sourceThumb ->
|
onPhotoPreviewRequested = { uri, sourceThumb ->
|
||||||
hideInputOverlays()
|
hideInputOverlays()
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
|
showContextMenu = false
|
||||||
|
contextMenuMessage = null
|
||||||
simplePickerPreviewSourceThumb = sourceThumb
|
simplePickerPreviewSourceThumb = sourceThumb
|
||||||
|
simplePickerPreviewCaption = ""
|
||||||
simplePickerPreviewUri = uri
|
simplePickerPreviewUri = uri
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3344,9 +3350,23 @@ fun ChatDetailScreen(
|
|||||||
imageUri = previewUri,
|
imageUri = previewUri,
|
||||||
sourceThumbnail = simplePickerPreviewSourceThumb,
|
sourceThumbnail = simplePickerPreviewSourceThumb,
|
||||||
modifier = Modifier.fillMaxSize().zIndex(100f),
|
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 = {
|
onDismiss = {
|
||||||
simplePickerPreviewUri = null
|
simplePickerPreviewUri = null
|
||||||
simplePickerPreviewSourceThumb = null
|
simplePickerPreviewSourceThumb = null
|
||||||
|
simplePickerPreviewCaption = ""
|
||||||
|
inputFocusTrigger++
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
* Telegram-style attach alert (media picker bottom sheet).
|
||||||
*
|
*
|
||||||
@@ -684,8 +691,17 @@ fun ChatAttachAlert(
|
|||||||
LaunchedEffect(shouldShow, state.editingItem) {
|
LaunchedEffect(shouldShow, state.editingItem) {
|
||||||
if (!shouldShow || state.editingItem != null) 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
|
||||||
@@ -695,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,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
|
||||||
@@ -835,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 =
|
||||||
@@ -1096,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 ──
|
||||||
@@ -1119,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
|
||||||
|
|||||||
@@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview(
|
|||||||
val cached = ImageBitmapCache.get(cacheKey)
|
val cached = ImageBitmapCache.get(cacheKey)
|
||||||
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
|
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
|
||||||
|
|
||||||
|
// 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл
|
||||||
|
if (attachment.localUri.isNotEmpty()) {
|
||||||
|
val localBitmap =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver
|
||||||
|
.openInputStream(
|
||||||
|
android.net.Uri.parse(
|
||||||
|
attachment.localUri
|
||||||
|
)
|
||||||
|
)
|
||||||
|
?.use { input ->
|
||||||
|
BitmapFactory.decodeStream(input)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
if (localBitmap != null) {
|
||||||
|
imageBitmap = localBitmap
|
||||||
|
ImageBitmapCache.put(cacheKey, localBitmap)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Try local file cache first
|
// Try local file cache first
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -829,7 +829,7 @@ fun ImageEditorScreen(
|
|||||||
* Telegram-style toolbar - icons only, no labels
|
* Telegram-style toolbar - icons only, no labels
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelegramToolbar(
|
internal fun TelegramToolbar(
|
||||||
currentTool: EditorTool,
|
currentTool: EditorTool,
|
||||||
showCaptionInput: Boolean,
|
showCaptionInput: Boolean,
|
||||||
isSaving: Boolean,
|
isSaving: Boolean,
|
||||||
@@ -958,7 +958,7 @@ private fun TelegramToolButton(
|
|||||||
* Telegram-style color picker with brush size
|
* Telegram-style color picker with brush size
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelegramColorPicker(
|
internal fun TelegramColorPicker(
|
||||||
selectedColor: Color,
|
selectedColor: Color,
|
||||||
brushSize: Float,
|
brushSize: Float,
|
||||||
onColorSelected: (Color) -> Unit,
|
onColorSelected: (Color) -> Unit,
|
||||||
@@ -1044,7 +1044,7 @@ private fun TelegramColorPicker(
|
|||||||
* Telegram-style rotate bar
|
* Telegram-style rotate bar
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelegramRotateBar(
|
internal fun TelegramRotateBar(
|
||||||
onRotateLeft: () -> Unit,
|
onRotateLeft: () -> Unit,
|
||||||
onRotateRight: () -> Unit,
|
onRotateRight: () -> Unit,
|
||||||
onFlipHorizontal: () -> Unit,
|
onFlipHorizontal: () -> Unit,
|
||||||
@@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Save edited image synchronously (with all editor changes). */
|
/** Save edited image synchronously (with all editor changes). */
|
||||||
private suspend fun saveEditedImageSync(
|
internal suspend fun saveEditedImageSync(
|
||||||
context: Context,
|
context: Context,
|
||||||
photoEditor: PhotoEditor?,
|
photoEditor: PhotoEditor?,
|
||||||
photoEditorView: PhotoEditorView?,
|
photoEditorView: PhotoEditorView?,
|
||||||
@@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair<Int, In
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Launch UCrop activity */
|
/** Launch UCrop activity */
|
||||||
private fun launchCrop(
|
internal fun launchCrop(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceUri: Uri,
|
sourceUri: Uri,
|
||||||
launcher: androidx.activity.result.ActivityResultLauncher<Intent>
|
launcher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
@@ -572,8 +579,17 @@ fun MediaPickerBottomSheet(
|
|||||||
if (!shouldShow || editingItem != null) 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()
|
||||||
@@ -585,11 +601,16 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,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
|
||||||
@@ -639,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 =
|
||||||
@@ -1151,6 +1173,9 @@ fun MediaPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (navInsetDpForSheet > 0.dp) {
|
||||||
|
Spacer(modifier = Modifier.height(navInsetDpForSheet))
|
||||||
|
}
|
||||||
} // end Column
|
} // end Column
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
@@ -1169,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
|
||||||
|
|||||||
@@ -1,46 +1,97 @@
|
|||||||
package com.rosetta.messenger.ui.chats.components
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Color as AndroidColor
|
import android.graphics.Color as AndroidColor
|
||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.Window
|
import android.view.Window
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import android.widget.ImageView
|
||||||
import androidx.activity.compose.BackHandler
|
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.Animatable
|
||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
import androidx.compose.animation.core.tween
|
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.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.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.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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
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.platform.LocalView
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private val ViewerExpandEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
private val ViewerExpandEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||||
@@ -104,9 +155,14 @@ private fun setupFullscreenWindow(window: Window?) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleFullscreenPhotoViewer(
|
fun SimpleFullscreenPhotoViewer(
|
||||||
imageUri: android.net.Uri,
|
imageUri: Uri,
|
||||||
onDismiss: () -> Unit,
|
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(
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -126,41 +182,117 @@ fun SimpleFullscreenPhotoViewer(
|
|||||||
SimpleFullscreenPhotoContent(
|
SimpleFullscreenPhotoContent(
|
||||||
imageUri = imageUri,
|
imageUri = imageUri,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
sourceThumbnail = sourceThumbnail
|
sourceThumbnail = sourceThumbnail,
|
||||||
|
showCaptionInput = showCaptionInput,
|
||||||
|
caption = caption,
|
||||||
|
onCaptionChange = onCaptionChange,
|
||||||
|
onSend = onSend,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleFullscreenPhotoOverlay(
|
fun SimpleFullscreenPhotoOverlay(
|
||||||
imageUri: android.net.Uri,
|
imageUri: Uri,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
sourceThumbnail: ThumbnailPosition? = null,
|
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(
|
SimpleFullscreenPhotoContent(
|
||||||
imageUri = imageUri,
|
imageUri = imageUri,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
sourceThumbnail = sourceThumbnail,
|
sourceThumbnail = sourceThumbnail,
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
|
showCaptionInput = showCaptionInput,
|
||||||
|
caption = caption,
|
||||||
|
onCaptionChange = onCaptionChange,
|
||||||
|
onSend = onSend,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SimpleFullscreenPhotoContent(
|
private fun SimpleFullscreenPhotoContent(
|
||||||
imageUri: android.net.Uri,
|
imageUri: Uri,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
sourceThumbnail: ThumbnailPosition? = null,
|
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()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var isClosing by remember { mutableStateOf(false) }
|
var isClosing by remember { mutableStateOf(false) }
|
||||||
var screenSize by remember { mutableStateOf(IntSize.Zero) }
|
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) {
|
val progress = remember(imageUri, sourceThumbnail) {
|
||||||
Animatable(if (sourceThumbnail != null) 0f else 1f)
|
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) {
|
LaunchedEffect(imageUri, sourceThumbnail) {
|
||||||
|
localCaption = caption
|
||||||
if (progress.value < 1f) {
|
if (progress.value < 1f) {
|
||||||
progress.animateTo(
|
progress.animateTo(
|
||||||
targetValue = 1f,
|
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() {
|
fun closeViewer() {
|
||||||
if (isClosing) return
|
if (isClosing) return
|
||||||
isClosing = true
|
isClosing = true
|
||||||
|
showEmojiPicker = false
|
||||||
|
hideKeyboard()
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
progress.animateTo(
|
progress.animateTo(
|
||||||
targetValue = 0f,
|
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() }
|
BackHandler { closeViewer() }
|
||||||
|
|
||||||
val transform by remember(sourceThumbnail, screenSize, progress.value) {
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier.fillMaxSize()
|
modifier.fillMaxSize()
|
||||||
.onSizeChanged { screenSize = it }
|
.onSizeChanged { screenSize = it }
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) },
|
.then(tapToDismissModifier),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AndroidView(
|
||||||
model = imageUri,
|
factory = { ctx ->
|
||||||
contentDescription = "Photo",
|
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 =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
@@ -244,8 +472,297 @@ private fun SimpleFullscreenPhotoContent(
|
|||||||
} else {
|
} else {
|
||||||
Modifier
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
|
||||||
import com.airbnb.lottie.compose.*
|
import com.airbnb.lottie.compose.*
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.ui.theme.*
|
import com.rosetta.messenger.ui.theme.*
|
||||||
@@ -124,8 +122,11 @@ fun OnboardingScreen(
|
|||||||
|
|
||||||
// Animate navigation bar color starting at 80% of wave animation
|
// Animate navigation bar color starting at 80% of wave animation
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
val isGestureNavigation = remember(view.context) {
|
||||||
|
NavigationModeUtils.isGestureNavigation(view.context)
|
||||||
|
}
|
||||||
LaunchedEffect(isTransitioning, transitionProgress) {
|
LaunchedEffect(isTransitioning, transitionProgress) {
|
||||||
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
// Map 0.8-1.0 to 0-1 for smooth interpolation
|
// Map 0.8-1.0 to 0-1 for smooth interpolation
|
||||||
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
|
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
|
||||||
@@ -163,7 +164,10 @@ fun OnboardingScreen(
|
|||||||
|
|
||||||
// Navigation bar: показываем только если есть нативные кнопки
|
// Navigation bar: показываем только если есть нативные кнопки
|
||||||
NavigationModeUtils.applyNavigationBarVisibility(
|
NavigationModeUtils.applyNavigationBarVisibility(
|
||||||
insetsController, view.context, isDarkTheme
|
window = window,
|
||||||
|
insetsController = insetsController,
|
||||||
|
context = view.context,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,10 +177,12 @@ fun OnboardingScreen(
|
|||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
window.navigationBarColor =
|
NavigationModeUtils.applyNavigationBarVisibility(
|
||||||
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
|
window = window,
|
||||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
insetsController = insetsController,
|
||||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
context = view.context,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
val isRosettaOfficial =
|
val isRosettaOfficial =
|
||||||
name.equals("Rosetta", ignoreCase = true) ||
|
name.equals("Rosetta", ignoreCase = true) ||
|
||||||
username.equals("rosetta", ignoreCase = true)
|
username.equals("rosetta", ignoreCase = true)
|
||||||
|
val isFreddyOfficial =
|
||||||
|
name.equals("freddy", ignoreCase = true) ||
|
||||||
|
username.equals("freddy", ignoreCase = true)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
||||||
@@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verified > 0 || isRosettaOfficial) {
|
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (verified > 0) verified else 1,
|
verified = if (verified > 0) verified else 1,
|
||||||
|
|||||||
@@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader(
|
|||||||
val isRosettaOfficial =
|
val isRosettaOfficial =
|
||||||
name.equals("Rosetta", ignoreCase = true) ||
|
name.equals("Rosetta", ignoreCase = true) ||
|
||||||
username.equals("rosetta", ignoreCase = true)
|
username.equals("rosetta", ignoreCase = true)
|
||||||
|
val isFreddyOfficial =
|
||||||
|
name.equals("freddy", ignoreCase = true) ||
|
||||||
|
username.equals("freddy", ignoreCase = true)
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||||
// Expansion fraction — computed early so gradient can fade during expansion
|
// Expansion fraction — computed early so gradient can fade during expansion
|
||||||
@@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader(
|
|||||||
modifier = Modifier.widthIn(max = 220.dp),
|
modifier = Modifier.widthIn(max = 220.dp),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
if (verified > 0 || isRosettaOfficial) {
|
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (verified > 0) verified else 2,
|
verified = if (verified > 0) verified else 2,
|
||||||
|
|||||||
@@ -68,15 +68,21 @@ fun RosettaAndroidTheme(
|
|||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
DisposableEffect(darkTheme, view, context) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
// Make status bar transparent for wave animation overlay
|
// Make status bar transparent for wave animation overlay
|
||||||
window.statusBarColor = AndroidColor.TRANSPARENT
|
window.statusBarColor = AndroidColor.TRANSPARENT
|
||||||
// Note: isAppearanceLightStatusBars is managed per-screen, not globally
|
// Note: isAppearanceLightStatusBars is managed per-screen, not globally
|
||||||
|
|
||||||
// Navigation bar: показываем только если есть нативные кнопки
|
NavigationModeUtils.applyNavigationBarVisibility(
|
||||||
NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme)
|
window = window,
|
||||||
|
insetsController = insetsController,
|
||||||
|
context = context,
|
||||||
|
isDarkTheme = darkTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
onDispose { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.rosetta.messenger.ui.utils
|
package com.rosetta.messenger.ui.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import android.view.Window
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -58,12 +62,42 @@ object NavigationModeUtils {
|
|||||||
* Показывает navigation bar на всех устройствах.
|
* Показывает navigation bar на всех устройствах.
|
||||||
*/
|
*/
|
||||||
fun applyNavigationBarVisibility(
|
fun applyNavigationBarVisibility(
|
||||||
|
window: Window,
|
||||||
insetsController: WindowInsetsControllerCompat,
|
insetsController: WindowInsetsControllerCompat,
|
||||||
context: Context,
|
context: Context,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean
|
||||||
) {
|
) {
|
||||||
|
val gestureNavigation = isGestureNavigation(context)
|
||||||
|
val decorView = window.decorView
|
||||||
|
|
||||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
if (gestureNavigation) {
|
||||||
|
// In gesture mode we keep a transparent nav bar and fixed white handle.
|
||||||
|
// This avoids the "jump" effect during theme switching.
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
|
insetsController.isAppearanceLightNavigationBars = false
|
||||||
|
val newFlags =
|
||||||
|
decorView.systemUiVisibility or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
if (newFlags != decorView.systemUiVisibility) {
|
||||||
|
decorView.systemUiVisibility = newFlags
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
|
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||||
|
val newFlags =
|
||||||
|
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
|
||||||
|
if (newFlags != decorView.systemUiVisibility) {
|
||||||
|
decorView.systemUiVisibility = newFlags
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user