Compare commits
2 Commits
7641fbc560
...
160ba4e2e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 160ba4e2e7 | |||
| aa096e2e87 |
@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -162,6 +163,9 @@ fun ChatDetailScreen(
|
||||
) {
|
||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||
val context = LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -290,6 +294,9 @@ fun ChatDetailScreen(
|
||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
|
||||
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
|
||||
var simplePickerPreviewCaption by remember { mutableStateOf("") }
|
||||
|
||||
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
|
||||
if (!view.isInEditMode) {
|
||||
@@ -364,7 +371,8 @@ fun ChatDetailScreen(
|
||||
showEmojiPicker,
|
||||
pendingCameraPhotoUri,
|
||||
pendingGalleryImages,
|
||||
showInAppCamera
|
||||
showInAppCamera,
|
||||
simplePickerPreviewUri
|
||||
) {
|
||||
derivedStateOf {
|
||||
showImageViewer ||
|
||||
@@ -372,7 +380,8 @@ fun ChatDetailScreen(
|
||||
showEmojiPicker ||
|
||||
pendingCameraPhotoUri != null ||
|
||||
pendingGalleryImages.isNotEmpty() ||
|
||||
showInAppCamera
|
||||
showInAppCamera ||
|
||||
simplePickerPreviewUri != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +389,13 @@ fun ChatDetailScreen(
|
||||
onImageViewerChanged(shouldLockParentSwipeBack)
|
||||
}
|
||||
|
||||
LaunchedEffect(simplePickerPreviewUri) {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { onImageViewerChanged(false) }
|
||||
}
|
||||
@@ -1801,7 +1817,10 @@ fun ChatDetailScreen(
|
||||
bottom =
|
||||
16.dp
|
||||
)
|
||||
.navigationBarsPadding()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
.graphicsLayer {
|
||||
scaleX =
|
||||
buttonScale
|
||||
@@ -1975,14 +1994,7 @@ fun ChatDetailScreen(
|
||||
it.type !=
|
||||
AttachmentType
|
||||
.MESSAGES
|
||||
}
|
||||
.map {
|
||||
attachment ->
|
||||
attachment.copy(
|
||||
localUri =
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -2014,14 +2026,7 @@ fun ChatDetailScreen(
|
||||
it.type !=
|
||||
AttachmentType
|
||||
.MESSAGES
|
||||
}
|
||||
.map {
|
||||
attachment ->
|
||||
attachment.copy(
|
||||
localUri =
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -2168,7 +2173,9 @@ fun ChatDetailScreen(
|
||||
inputFocusTrigger =
|
||||
inputFocusTrigger,
|
||||
suppressKeyboard =
|
||||
showInAppCamera
|
||||
showInAppCamera,
|
||||
hasNativeNavigationBar =
|
||||
hasNativeNavigationBar
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2513,6 +2520,9 @@ fun ChatDetailScreen(
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
onLongClick = {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
return@MessageBubble
|
||||
}
|
||||
// 📳 Haptic feedback при долгом нажатии
|
||||
// Не разрешаем выделять avatar-сообщения
|
||||
val hasAvatar =
|
||||
@@ -2553,6 +2563,9 @@ fun ChatDetailScreen(
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
return@MessageBubble
|
||||
}
|
||||
if (shouldIgnoreTapAfterLongPress(
|
||||
selectionKey
|
||||
)
|
||||
@@ -3005,7 +3018,16 @@ fun ChatDetailScreen(
|
||||
onAvatarClick = {
|
||||
viewModel.sendAvatarMessage()
|
||||
},
|
||||
recipientName = user.title
|
||||
recipientName = user.title,
|
||||
onPhotoPreviewRequested = { uri, sourceThumb ->
|
||||
hideInputOverlays()
|
||||
showMediaPicker = false
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
simplePickerPreviewSourceThumb = sourceThumb
|
||||
simplePickerPreviewCaption = ""
|
||||
simplePickerPreviewUri = uri
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MediaPickerBottomSheet(
|
||||
@@ -3049,7 +3071,16 @@ fun ChatDetailScreen(
|
||||
onAvatarClick = {
|
||||
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
|
||||
@@ -3314,6 +3345,32 @@ fun ChatDetailScreen(
|
||||
|
||||
} // Закрытие 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
|
||||
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||
ImageViewerScreen(
|
||||
|
||||
@@ -1582,10 +1582,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attBlob = attJson.optString("blob", "")
|
||||
val attWidth = attJson.optInt("width", 0)
|
||||
val attHeight = attJson.optInt("height", 0)
|
||||
val attLocalUri = attJson.optString("localUri", "")
|
||||
if (attId.isNotEmpty()) {
|
||||
fwdAttachments.add(MessageAttachment(
|
||||
id = attId, type = attType, preview = attPreview,
|
||||
blob = attBlob, width = attWidth, height = attHeight
|
||||
blob = attBlob, width = attWidth, height = attHeight,
|
||||
localUri = attLocalUri
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1662,6 +1664,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attBlob = attJson.optString("blob", "")
|
||||
val attWidth = attJson.optInt("width", 0)
|
||||
val attHeight = attJson.optInt("height", 0)
|
||||
val attLocalUri = attJson.optString("localUri", "")
|
||||
|
||||
if (attId.isNotEmpty()) {
|
||||
replyAttachmentsFromJson.add(
|
||||
@@ -1671,7 +1674,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
preview = attPreview,
|
||||
blob = attBlob,
|
||||
width = attWidth,
|
||||
height = attHeight
|
||||
height = attHeight,
|
||||
localUri = attLocalUri
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2566,12 +2570,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val privateKey = myPrivateKey ?: return
|
||||
if (forwardMessages.isEmpty()) return
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val isCurrentDialogTarget = recipientPublicKey == opponentKey
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val isSavedMessages = (sender == recipientPublicKey)
|
||||
val db = RosettaDatabase.getDatabase(context)
|
||||
val dialogDao = db.dialogDao()
|
||||
|
||||
suspend fun refreshTargetDialog() {
|
||||
if (isSavedMessages) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(sender)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Шифрование (пустой текст для forward)
|
||||
val encryptionContext =
|
||||
@@ -2584,11 +2600,133 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
var replyBlobForDatabase = ""
|
||||
fun buildForwardReplyJson(
|
||||
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
|
||||
includeLocalUri: Boolean
|
||||
): JSONArray {
|
||||
val replyJsonArray = JSONArray()
|
||||
forwardMessages.forEach { fm ->
|
||||
val attachmentsArray = JSONArray()
|
||||
fm.attachments.forEach { att ->
|
||||
val fwdInfo = forwardedIdMap[att.id]
|
||||
val attId = fwdInfo?.first ?: att.id
|
||||
val attPreview = fwdInfo?.second ?: att.preview
|
||||
|
||||
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", attId)
|
||||
put("type", att.type.value)
|
||||
put("preview", attPreview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||
if (includeLocalUri && att.localUri.isNotEmpty()) {
|
||||
put("localUri", att.localUri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
replyJsonArray.put(
|
||||
JSONObject().apply {
|
||||
put("message_id", fm.messageId)
|
||||
put("publicKey", fm.senderPublicKey)
|
||||
put("message", fm.text)
|
||||
put("timestamp", fm.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", fm.senderName)
|
||||
}
|
||||
)
|
||||
}
|
||||
return replyJsonArray
|
||||
}
|
||||
|
||||
// 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге
|
||||
if (isCurrentDialogTarget) {
|
||||
val optimisticForwardedMessages =
|
||||
forwardMessages.map { fm ->
|
||||
val senderDisplayName =
|
||||
fm.senderName.ifEmpty {
|
||||
if (fm.senderPublicKey == sender) "You" else "User"
|
||||
}
|
||||
ReplyData(
|
||||
messageId = fm.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = fm.text,
|
||||
isFromMe = fm.senderPublicKey == sender,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES },
|
||||
senderPublicKey = fm.senderPublicKey,
|
||||
recipientPrivateKey = privateKey
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
addMessageSafely(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
forwardedMessages = optimisticForwardedMessages
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу
|
||||
val optimisticReplyBlobPlaintext =
|
||||
buildForwardReplyJson(includeLocalUri = true).toString()
|
||||
val optimisticReplyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
|
||||
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", replyAttachmentId)
|
||||
put("type", AttachmentType.MESSAGES.value)
|
||||
put("preview", "")
|
||||
put("width", 0)
|
||||
put("height", 0)
|
||||
put("blob", optimisticReplyBlobForDatabase)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
saveMessageToDatabase(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
opponentPublicKey = recipientPublicKey
|
||||
)
|
||||
refreshTargetDialog()
|
||||
|
||||
if (isSavedMessages && isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
|
||||
// Map: originalAttId → (newAttId, newPreview)
|
||||
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
|
||||
var fwdIdx = 0
|
||||
@@ -2631,47 +2769,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
// Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками
|
||||
val replyJsonArray = JSONArray()
|
||||
forwardMessages.forEach { fm ->
|
||||
val attachmentsArray = JSONArray()
|
||||
fm.attachments.forEach { att ->
|
||||
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
|
||||
val fwdInfo = forwardedAttMap[att.id]
|
||||
val attId = fwdInfo?.first ?: att.id
|
||||
val attPreview = fwdInfo?.second ?: att.preview
|
||||
|
||||
attachmentsArray.put(JSONObject().apply {
|
||||
put("id", attId)
|
||||
put("type", att.type.value)
|
||||
put("preview", attPreview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||
})
|
||||
}
|
||||
replyJsonArray.put(JSONObject().apply {
|
||||
put("message_id", fm.messageId)
|
||||
put("publicKey", fm.senderPublicKey)
|
||||
put("message", fm.text)
|
||||
put("timestamp", fm.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", fm.senderName)
|
||||
})
|
||||
}
|
||||
|
||||
val replyBlobPlaintext = replyJsonArray.toString()
|
||||
val replyBlobPlaintext =
|
||||
buildForwardReplyJson(
|
||||
forwardedIdMap = forwardedAttMap,
|
||||
includeLocalUri = false
|
||||
)
|
||||
.toString()
|
||||
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
|
||||
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
val replyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
messageAttachments.add(MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
))
|
||||
val finalMessageAttachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
)
|
||||
)
|
||||
|
||||
// Отправляем пакет
|
||||
val packet = PacketMessage().apply {
|
||||
@@ -2683,58 +2799,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
attachments = messageAttachments
|
||||
attachments = finalMessageAttachments
|
||||
}
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
|
||||
// Сохраняем в БД
|
||||
val attachmentsJson = JSONArray().apply {
|
||||
messageAttachments.forEach { att ->
|
||||
put(JSONObject().apply {
|
||||
put("id", att.id)
|
||||
put("type", att.type.value)
|
||||
put("preview", att.preview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", when (att.type) {
|
||||
AttachmentType.MESSAGES -> replyBlobForDatabase
|
||||
else -> ""
|
||||
})
|
||||
})
|
||||
}
|
||||
}.toString()
|
||||
val finalAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
finalMessageAttachments.forEach { att ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", att.id)
|
||||
put("type", att.type.value)
|
||||
put("preview", att.preview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put(
|
||||
"blob",
|
||||
when (att.type) {
|
||||
AttachmentType.MESSAGES ->
|
||||
replyBlobForDatabase
|
||||
else -> ""
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
|
||||
saveMessageToDatabase(
|
||||
updateMessageStatusAndAttachmentsInDb(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
opponentPublicKey = recipientPublicKey
|
||||
delivered = 1,
|
||||
attachmentsJson = finalAttachmentsJson
|
||||
)
|
||||
|
||||
// Обновляем диалог (для списка чатов) из таблицы сообщений.
|
||||
val db = RosettaDatabase.getDatabase(context)
|
||||
val dialogDao = db.dialogDao()
|
||||
if (isSavedMessages) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(sender)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) { }
|
||||
refreshTargetDialog()
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,9 @@ fun ChatsListScreen(
|
||||
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -443,10 +446,6 @@ fun ChatsListScreen(
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
|
||||
// Navigation bar
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils
|
||||
.applyNavigationBarVisibility(insetsController, context, isDarkTheme)
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
@@ -754,7 +753,10 @@ fun ChatsListScreen(
|
||||
Modifier.fillMaxSize()
|
||||
.onSizeChanged { rootSize = it }
|
||||
.background(backgroundColor)
|
||||
.navigationBarsPadding()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -812,6 +814,15 @@ fun ChatsListScreen(
|
||||
"rosetta",
|
||||
ignoreCase = true
|
||||
)
|
||||
val isFreddyOfficial =
|
||||
accountName.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"freddy",
|
||||
ignoreCase = true
|
||||
)
|
||||
// Avatar row with theme toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -925,7 +936,7 @@ fun ChatsListScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
if (accountVerified > 0 || isRosettaOfficial) {
|
||||
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(
|
||||
@@ -935,7 +946,7 @@ fun ChatsListScreen(
|
||||
VerifiedBadge(
|
||||
verified = if (accountVerified > 0) accountVerified else 1,
|
||||
size = 15,
|
||||
badgeTint = PrimaryBlue
|
||||
badgeTint = if (isDarkTheme) Color.White else PrimaryBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1230,7 +1241,14 @@ fun ChatsListScreen(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// FOOTER - Version + Update Banner
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
// Telegram-style update banner
|
||||
val curUpdate = sduUpdateState
|
||||
val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable ||
|
||||
@@ -3886,7 +3904,10 @@ fun DialogItemContent(
|
||||
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
if (dialog.verified > 0 || isRosettaOfficial) {
|
||||
val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
|
||||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
|
||||
displayName.equals("freddy", ignoreCase = true)
|
||||
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
|
||||
import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen
|
||||
import com.rosetta.messenger.ui.chats.components.SimpleFullscreenPhotoViewer
|
||||
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
|
||||
@@ -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).
|
||||
*
|
||||
@@ -146,6 +154,7 @@ fun ChatAttachAlert(
|
||||
currentUserPublicKey: String = "",
|
||||
maxSelection: Int = 10,
|
||||
recipientName: String? = null,
|
||||
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null,
|
||||
viewModel: AttachAlertViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -679,11 +688,20 @@ fun ChatAttachAlert(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldShow) {
|
||||
if (!shouldShow) return@LaunchedEffect
|
||||
LaunchedEffect(shouldShow, state.editingItem) {
|
||||
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
|
||||
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) }
|
||||
.collect { (alpha, fullScreen, dark) ->
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
@@ -693,8 +711,16 @@ fun ChatAttachAlert(
|
||||
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
|
||||
// Telegram-like: nav bar follows picker surface, not black scrim.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,7 +736,7 @@ fun ChatAttachAlert(
|
||||
// POPUP RENDERING
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
if (shouldShow) {
|
||||
if (shouldShow && state.editingItem == null) {
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
onDismissRequest = {
|
||||
@@ -823,8 +849,9 @@ fun ChatAttachAlert(
|
||||
} else keyboardSpacerDp
|
||||
|
||||
// When keyboard or emoji is open, nav bar is behind — don't pad for it
|
||||
val navBarDp = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0.dp
|
||||
else with(density) { navigationBarInsetPx.toDp() }
|
||||
val navInsetPxForSheet = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0f
|
||||
else navigationBarInsetPx
|
||||
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -833,13 +860,12 @@ fun ChatAttachAlert(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { requestClose() }
|
||||
.padding(bottom = navBarDp),
|
||||
) { requestClose() },
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
// Sheet height stays constant — keyboard space is handled by
|
||||
// internal Spacer, not by shrinking the container (Telegram approach).
|
||||
val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx)
|
||||
val visibleSheetHeightPx = (sheetHeightPx.value + navInsetPxForSheet).coerceAtLeast(minHeightPx)
|
||||
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||
val expandProgress =
|
||||
@@ -976,8 +1002,13 @@ fun ChatAttachAlert(
|
||||
},
|
||||
onItemClick = { item, position ->
|
||||
if (!item.isVideo) {
|
||||
thumbnailPosition = position
|
||||
viewModel.setEditingItem(item)
|
||||
hideKeyboard()
|
||||
if (onPhotoPreviewRequested != null) {
|
||||
onPhotoPreviewRequested(item.uri, position)
|
||||
} else {
|
||||
thumbnailPosition = position
|
||||
viewModel.setEditingItem(item)
|
||||
}
|
||||
} else {
|
||||
viewModel.toggleSelection(item.id, maxSelection)
|
||||
}
|
||||
@@ -1089,6 +1120,9 @@ fun ChatAttachAlert(
|
||||
if (!coordinator.isEmojiBoxVisible) {
|
||||
Spacer(modifier = Modifier.height(keyboardSpacerDp))
|
||||
}
|
||||
if (navInsetDpForSheet > 0.dp) {
|
||||
Spacer(modifier = Modifier.height(navInsetDpForSheet))
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// ── Floating Send Button ──
|
||||
@@ -1112,7 +1146,7 @@ fun ChatAttachAlert(
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = bottomInputPadding)
|
||||
.padding(bottom = bottomInputPadding + navInsetDpForSheet)
|
||||
)
|
||||
|
||||
} // end Box sheet container
|
||||
@@ -1173,45 +1207,14 @@ fun ChatAttachAlert(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
state.editingItem?.let { item ->
|
||||
ImageEditorScreen(
|
||||
SimpleFullscreenPhotoViewer(
|
||||
imageUri = item.uri,
|
||||
sourceThumbnail = thumbnailPosition,
|
||||
onDismiss = {
|
||||
viewModel.setEditingItem(null)
|
||||
thumbnailPosition = null
|
||||
shouldShow = true
|
||||
},
|
||||
onSave = { editedUri ->
|
||||
viewModel.setEditingItem(null)
|
||||
thumbnailPosition = null
|
||||
if (onMediaSelectedWithCaption == null) {
|
||||
previewPhotoUri = editedUri
|
||||
} else {
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelected(listOf(mediaItem), "")
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
|
||||
viewModel.setEditingItem(null)
|
||||
thumbnailPosition = null
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelectedWithCaption(mediaItem, caption)
|
||||
onDismiss()
|
||||
} else null,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showCaptionInput = onMediaSelectedWithCaption != null,
|
||||
recipientName = recipientName,
|
||||
thumbnailPosition = thumbnailPosition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview(
|
||||
val cached = ImageBitmapCache.get(cacheKey)
|
||||
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
|
||||
|
||||
// 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл
|
||||
if (attachment.localUri.isNotEmpty()) {
|
||||
val localBitmap =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver
|
||||
.openInputStream(
|
||||
android.net.Uri.parse(
|
||||
attachment.localUri
|
||||
)
|
||||
)
|
||||
?.use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
if (localBitmap != null) {
|
||||
imageBitmap = localBitmap
|
||||
ImageBitmapCache.put(cacheKey, localBitmap)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Try local file cache first
|
||||
try {
|
||||
|
||||
@@ -829,7 +829,7 @@ fun ImageEditorScreen(
|
||||
* Telegram-style toolbar - icons only, no labels
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramToolbar(
|
||||
internal fun TelegramToolbar(
|
||||
currentTool: EditorTool,
|
||||
showCaptionInput: Boolean,
|
||||
isSaving: Boolean,
|
||||
@@ -958,7 +958,7 @@ private fun TelegramToolButton(
|
||||
* Telegram-style color picker with brush size
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramColorPicker(
|
||||
internal fun TelegramColorPicker(
|
||||
selectedColor: Color,
|
||||
brushSize: Float,
|
||||
onColorSelected: (Color) -> Unit,
|
||||
@@ -1044,7 +1044,7 @@ private fun TelegramColorPicker(
|
||||
* Telegram-style rotate bar
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramRotateBar(
|
||||
internal fun TelegramRotateBar(
|
||||
onRotateLeft: () -> Unit,
|
||||
onRotateRight: () -> Unit,
|
||||
onFlipHorizontal: () -> Unit,
|
||||
@@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld(
|
||||
}
|
||||
|
||||
/** Save edited image synchronously (with all editor changes). */
|
||||
private suspend fun saveEditedImageSync(
|
||||
internal suspend fun saveEditedImageSync(
|
||||
context: Context,
|
||||
photoEditor: PhotoEditor?,
|
||||
photoEditorView: PhotoEditorView?,
|
||||
@@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair<Int, In
|
||||
}
|
||||
|
||||
/** Launch UCrop activity */
|
||||
private fun launchCrop(
|
||||
internal fun launchCrop(
|
||||
context: Context,
|
||||
sourceUri: Uri,
|
||||
launcher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||
|
||||
@@ -82,6 +82,13 @@ import kotlin.math.roundToInt
|
||||
private const val TAG = "MediaPickerBottomSheet"
|
||||
private const val ALL_MEDIA_ALBUM_ID = 0L
|
||||
|
||||
private data class PickerSystemBarsSnapshot(
|
||||
val scrimAlpha: Float,
|
||||
val isFullScreen: Boolean,
|
||||
val isDarkTheme: Boolean,
|
||||
val openProgress: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Media item from gallery
|
||||
*/
|
||||
@@ -125,7 +132,8 @@ fun MediaPickerBottomSheet(
|
||||
onAvatarClick: () -> Unit = {},
|
||||
currentUserPublicKey: String = "",
|
||||
maxSelection: Int = 10,
|
||||
recipientName: String? = null
|
||||
recipientName: String? = null,
|
||||
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -567,12 +575,21 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
|
||||
// Reactive updates — single snapshotFlow drives ALL system bar colors
|
||||
LaunchedEffect(shouldShow) {
|
||||
if (!shouldShow) return@LaunchedEffect
|
||||
LaunchedEffect(shouldShow, editingItem) {
|
||||
if (!shouldShow || editingItem != null) return@LaunchedEffect
|
||||
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
|
||||
|
||||
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) }
|
||||
.collect { (alpha, fullScreen, dark) ->
|
||||
snapshotFlow {
|
||||
PickerSystemBarsSnapshot(
|
||||
scrimAlpha = scrimAlpha,
|
||||
isFullScreen = isPickerFullScreen,
|
||||
isDarkTheme = isDarkTheme,
|
||||
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
|
||||
)
|
||||
}.collect { state ->
|
||||
val alpha = state.scrimAlpha
|
||||
val fullScreen = state.isFullScreen
|
||||
val dark = state.isDarkTheme
|
||||
if (fullScreen) {
|
||||
// Full screen: status bar = picker background, seamless
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
@@ -584,16 +601,21 @@ fun MediaPickerBottomSheet(
|
||||
)
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
}
|
||||
// Navigation bar always follows scrim
|
||||
// Telegram-like: nav bar follows picker surface, not scrim.
|
||||
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
|
||||
window.navigationBarColor = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
|
||||
navAlpha,
|
||||
android.graphics.Color.red(navBaseColor),
|
||||
android.graphics.Color.green(navBaseColor),
|
||||
android.graphics.Color.blue(navBaseColor)
|
||||
)
|
||||
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
|
||||
insetsController?.isAppearanceLightNavigationBars = !dark
|
||||
}
|
||||
}
|
||||
|
||||
// Используем Popup для показа поверх клавиатуры
|
||||
if (shouldShow) {
|
||||
if (shouldShow && editingItem == null) {
|
||||
// BackHandler для закрытия по back
|
||||
BackHandler {
|
||||
if (isExpanded) {
|
||||
@@ -627,7 +649,8 @@ fun MediaPickerBottomSheet(
|
||||
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
|
||||
val appliedKeyboardInsetPx =
|
||||
if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f
|
||||
val navBarDp = with(density) { navigationBarInsetPx.toDp() }
|
||||
val navInsetPxForSheet = if (appliedKeyboardInsetPx > 0f) 0f else navigationBarInsetPx
|
||||
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
|
||||
|
||||
// Полноэкранный контейнер с мягким затемнением
|
||||
// background BEFORE padding — scrim covers area behind keyboard too
|
||||
@@ -638,14 +661,14 @@ fun MediaPickerBottomSheet(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { requestClose() }
|
||||
.padding(bottom = navBarDp),
|
||||
) { requestClose() },
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
// Subtract keyboard from sheet height so it fits in the resized viewport.
|
||||
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
|
||||
val visibleSheetHeightPx =
|
||||
(sheetHeightPx.value - appliedKeyboardInsetPx).coerceAtLeast(minHeightPx)
|
||||
(sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet)
|
||||
.coerceAtLeast(minHeightPx)
|
||||
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||
val expandProgress =
|
||||
@@ -986,8 +1009,13 @@ fun MediaPickerBottomSheet(
|
||||
},
|
||||
onItemClick = { item, position ->
|
||||
if (!item.isVideo) {
|
||||
thumbnailPosition = position
|
||||
editingItem = item
|
||||
hideKeyboard()
|
||||
if (onPhotoPreviewRequested != null) {
|
||||
onPhotoPreviewRequested(item.uri, position)
|
||||
} else {
|
||||
thumbnailPosition = position
|
||||
editingItem = item
|
||||
}
|
||||
} else {
|
||||
// Videos don't have photo editor in this flow.
|
||||
toggleSelection(item.id)
|
||||
@@ -1145,6 +1173,9 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (navInsetDpForSheet > 0.dp) {
|
||||
Spacer(modifier = Modifier.height(navInsetDpForSheet))
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
@@ -1163,7 +1194,7 @@ fun MediaPickerBottomSheet(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 14.dp, bottom = 8.dp)
|
||||
.padding(end = 14.dp, bottom = 8.dp + navInsetDpForSheet)
|
||||
.graphicsLayer {
|
||||
scaleX = sendScale
|
||||
scaleY = sendScale
|
||||
@@ -1279,48 +1310,16 @@ fun MediaPickerBottomSheet(
|
||||
)
|
||||
}
|
||||
|
||||
// Image Editor FULLSCREEN overlay для фото из галереи
|
||||
// ImageEditorScreen wraps itself in a Dialog internally — no external wrapper needed
|
||||
// Fullscreen preview для выбранной фото из галереи (чистый экран без тулбаров).
|
||||
editingItem?.let { item ->
|
||||
ImageEditorScreen(
|
||||
SimpleFullscreenPhotoViewer(
|
||||
imageUri = item.uri,
|
||||
sourceThumbnail = thumbnailPosition,
|
||||
onDismiss = {
|
||||
editingItem = null
|
||||
thumbnailPosition = null
|
||||
shouldShow = true
|
||||
},
|
||||
onSave = { editedUri ->
|
||||
editingItem = null
|
||||
thumbnailPosition = null
|
||||
if (onMediaSelectedWithCaption == null) {
|
||||
previewPhotoUri = editedUri
|
||||
} else {
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelected(listOf(mediaItem), "")
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
|
||||
editingItem = null
|
||||
thumbnailPosition = null
|
||||
val mediaItem = MediaItem(
|
||||
id = System.currentTimeMillis(),
|
||||
uri = editedUri,
|
||||
mimeType = "image/png",
|
||||
dateModified = System.currentTimeMillis()
|
||||
)
|
||||
onMediaSelectedWithCaption(mediaItem, caption)
|
||||
onDismiss()
|
||||
} else null,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showCaptionInput = onMediaSelectedWithCaption != null,
|
||||
recipientName = recipientName,
|
||||
thumbnailPosition = thumbnailPosition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,8 @@ fun MessageInputBar(
|
||||
mentionCandidates: List<MentionCandidate> = emptyList(),
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
inputFocusTrigger: Int = 0,
|
||||
suppressKeyboard: Boolean = false
|
||||
suppressKeyboard: Boolean = false,
|
||||
hasNativeNavigationBar: Boolean = true
|
||||
) {
|
||||
val hasReply = replyMessages.isNotEmpty()
|
||||
val liveReplyMessages =
|
||||
@@ -367,7 +368,10 @@ fun MessageInputBar(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.padding(bottom = 20.dp)
|
||||
.navigationBarsPadding(),
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
@@ -398,7 +402,10 @@ fun MessageInputBar(
|
||||
)
|
||||
)
|
||||
|
||||
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
|
||||
val shouldAddNavBarPadding =
|
||||
hasNativeNavigationBar &&
|
||||
!isKeyboardVisible &&
|
||||
!coordinator.isEmojiBoxVisible
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -45,8 +45,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.ui.theme.*
|
||||
@@ -124,8 +122,11 @@ fun OnboardingScreen(
|
||||
|
||||
// Animate navigation bar color starting at 80% of wave animation
|
||||
val view = LocalView.current
|
||||
val isGestureNavigation = remember(view.context) {
|
||||
NavigationModeUtils.isGestureNavigation(view.context)
|
||||
}
|
||||
LaunchedEffect(isTransitioning, transitionProgress) {
|
||||
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
||||
if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
// Map 0.8-1.0 to 0-1 for smooth interpolation
|
||||
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
|
||||
@@ -163,7 +164,10 @@ fun OnboardingScreen(
|
||||
|
||||
// Navigation bar: показываем только если есть нативные кнопки
|
||||
NavigationModeUtils.applyNavigationBarVisibility(
|
||||
insetsController, view.context, isDarkTheme
|
||||
window = window,
|
||||
insetsController = insetsController,
|
||||
context = view.context,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,10 +177,12 @@ fun OnboardingScreen(
|
||||
if (!view.isInEditMode) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
window.navigationBarColor =
|
||||
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
NavigationModeUtils.applyNavigationBarVisibility(
|
||||
window = window,
|
||||
insetsController = insetsController,
|
||||
context = view.context,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader(
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
||||
@@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (verified > 0 || isRosettaOfficial) {
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (verified > 0) verified else 1,
|
||||
|
||||
@@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader(
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
val isFreddyOfficial =
|
||||
name.equals("freddy", ignoreCase = true) ||
|
||||
username.equals("freddy", ignoreCase = true)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so gradient can fade during expansion
|
||||
@@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader(
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (verified > 0 || isRosettaOfficial) {
|
||||
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (verified > 0) verified else 2,
|
||||
|
||||
@@ -68,15 +68,21 @@ fun RosettaAndroidTheme(
|
||||
val view = LocalView.current
|
||||
val context = LocalContext.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
DisposableEffect(darkTheme, view, context) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
// Make status bar transparent for wave animation overlay
|
||||
window.statusBarColor = AndroidColor.TRANSPARENT
|
||||
// Note: isAppearanceLightStatusBars is managed per-screen, not globally
|
||||
|
||||
// Navigation bar: показываем только если есть нативные кнопки
|
||||
NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme)
|
||||
NavigationModeUtils.applyNavigationBarVisibility(
|
||||
window = window,
|
||||
insetsController = insetsController,
|
||||
context = context,
|
||||
isDarkTheme = darkTheme
|
||||
)
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.rosetta.messenger.ui.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -58,12 +62,42 @@ object NavigationModeUtils {
|
||||
* Показывает navigation bar на всех устройствах.
|
||||
*/
|
||||
fun applyNavigationBarVisibility(
|
||||
window: Window,
|
||||
insetsController: WindowInsetsControllerCompat,
|
||||
context: Context,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val gestureNavigation = isGestureNavigation(context)
|
||||
val decorView = window.decorView
|
||||
|
||||
insetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
if (gestureNavigation) {
|
||||
// In gesture mode we keep a transparent nav bar and fixed white handle.
|
||||
// This avoids the "jump" effect during theme switching.
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
insetsController.isAppearanceLightNavigationBars = false
|
||||
val newFlags =
|
||||
decorView.systemUiVisibility or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
if (newFlags != decorView.systemUiVisibility) {
|
||||
decorView.systemUiVisibility = newFlags
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
} else {
|
||||
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
val newFlags =
|
||||
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
|
||||
if (newFlags != decorView.systemUiVisibility) {
|
||||
decorView.systemUiVisibility = newFlags
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user