Доработан fullscreen фото-экран: добавлены инструменты редактирования, исправлены оверлеи и ускорена пересылка фото через optimistic UI

This commit is contained in:
2026-03-13 18:44:20 +07:00
parent aa096e2e87
commit 160ba4e2e7
14 changed files with 978 additions and 171 deletions

View File

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

View File

@@ -1582,10 +1582,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attBlob = attJson.optString("blob", "") val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
fwdAttachments.add(MessageAttachment( fwdAttachments.add(MessageAttachment(
id = attId, type = attType, preview = attPreview, id = attId, type = attType, preview = attPreview,
blob = attBlob, width = attWidth, height = attHeight blob = attBlob, width = attWidth, height = attHeight,
localUri = attLocalUri
)) ))
} }
} }
@@ -1662,6 +1664,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attBlob = attJson.optString("blob", "") val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add( replyAttachmentsFromJson.add(
@@ -1671,7 +1674,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
preview = attPreview, preview = attPreview,
blob = attBlob, blob = attBlob,
width = attWidth, width = attWidth,
height = attHeight height = attHeight,
localUri = attLocalUri
) )
) )
} }
@@ -2566,12 +2570,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return val privateKey = myPrivateKey ?: return
if (forwardMessages.isEmpty()) return if (forwardMessages.isEmpty()) return
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isCurrentDialogTarget = recipientPublicKey == opponentKey
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val context = getApplication<Application>() val context = getApplication<Application>()
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isSavedMessages = (sender == recipientPublicKey) val isSavedMessages = (sender == recipientPublicKey)
val db = RosettaDatabase.getDatabase(context)
val dialogDao = db.dialogDao()
suspend fun refreshTargetDialog() {
if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(sender)
} else {
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
}
}
// Шифрование (пустой текст для forward) // Шифрование (пустой текст для forward)
val encryptionContext = val encryptionContext =
@@ -2584,11 +2600,133 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedKey = encryptionContext.encryptedKey val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val replyAttachmentId = "reply_${timestamp}"
val messageAttachments = mutableListOf<MessageAttachment>() fun buildForwardReplyJson(
var replyBlobForDatabase = "" forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
includeLocalUri: Boolean
): JSONArray {
val replyJsonArray = JSONArray()
forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob attachmentsArray.put(
JSONObject().apply {
put("id", attId)
put("type", att.type.value)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri)
}
}
)
}
replyJsonArray.put(
JSONObject().apply {
put("message_id", fm.messageId)
put("publicKey", fm.senderPublicKey)
put("message", fm.text)
put("timestamp", fm.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
}
)
}
return replyJsonArray
}
// 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге
if (isCurrentDialogTarget) {
val optimisticForwardedMessages =
forwardMessages.map { fm ->
val senderDisplayName =
fm.senderName.ifEmpty {
if (fm.senderPublicKey == sender) "You" else "User"
}
ReplyData(
messageId = fm.messageId,
senderName = senderDisplayName,
text = fm.text,
isFromMe = fm.senderPublicKey == sender,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES },
senderPublicKey = fm.senderPublicKey,
recipientPrivateKey = privateKey
)
}
withContext(Dispatchers.Main) {
addMessageSafely(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
forwardedMessages = optimisticForwardedMessages
)
)
}
}
// 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу
val optimisticReplyBlobPlaintext =
buildForwardReplyJson(includeLocalUri = true).toString()
val optimisticReplyBlobForDatabase =
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", replyAttachmentId)
put("type", AttachmentType.MESSAGES.value)
put("preview", "")
put("width", 0)
put("height", 0)
put("blob", optimisticReplyBlobForDatabase)
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipientPublicKey
)
refreshTargetDialog()
if (isSavedMessages && isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId → (newAttId, newPreview) // Map: originalAttId → (newAttId, newPreview)
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>() val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
var fwdIdx = 0 var fwdIdx = 0
@@ -2631,47 +2769,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками val replyBlobPlaintext =
val replyJsonArray = JSONArray() buildForwardReplyJson(
forwardMessages.forEach { fm -> forwardedIdMap = forwardedAttMap,
val attachmentsArray = JSONArray() includeLocalUri = false
fm.attachments.forEach { att -> )
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag) .toString()
val fwdInfo = forwardedAttMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
attachmentsArray.put(JSONObject().apply {
put("id", attId)
put("type", att.type.value)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
})
}
replyJsonArray.put(JSONObject().apply {
put("message_id", fm.messageId)
put("publicKey", fm.senderPublicKey)
put("message", fm.text)
put("timestamp", fm.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
})
}
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext) val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) val replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyAttachmentId = "reply_${timestamp}" val finalMessageAttachments =
messageAttachments.add(MessageAttachment( listOf(
id = replyAttachmentId, MessageAttachment(
blob = encryptedReplyBlob, id = replyAttachmentId,
type = AttachmentType.MESSAGES, blob = encryptedReplyBlob,
preview = "" type = AttachmentType.MESSAGES,
)) preview = ""
)
)
// Отправляем пакет // Отправляем пакет
val packet = PacketMessage().apply { val packet = PacketMessage().apply {
@@ -2683,58 +2799,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
this.timestamp = timestamp this.timestamp = timestamp
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
this.messageId = messageId this.messageId = messageId
attachments = messageAttachments attachments = finalMessageAttachments
} }
if (!isSavedMessages) { if (!isSavedMessages) {
ProtocolManager.send(packet) ProtocolManager.send(packet)
} }
// Сохраняем в БД val finalAttachmentsJson =
val attachmentsJson = JSONArray().apply { JSONArray()
messageAttachments.forEach { att -> .apply {
put(JSONObject().apply { finalMessageAttachments.forEach { att ->
put("id", att.id) put(
put("type", att.type.value) JSONObject().apply {
put("preview", att.preview) put("id", att.id)
put("width", att.width) put("type", att.type.value)
put("height", att.height) put("preview", att.preview)
put("blob", when (att.type) { put("width", att.width)
AttachmentType.MESSAGES -> replyBlobForDatabase put("height", att.height)
else -> "" put(
}) "blob",
}) when (att.type) {
} AttachmentType.MESSAGES ->
}.toString() replyBlobForDatabase
else -> ""
}
)
}
)
}
}
.toString()
saveMessageToDatabase( updateMessageStatusAndAttachmentsInDb(
messageId = messageId, messageId = messageId,
text = "", delivered = 1,
encryptedContent = encryptedContent, attachmentsJson = finalAttachmentsJson
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
opponentPublicKey = recipientPublicKey
) )
// Обновляем диалог (для списка чатов) из таблицы сообщений. if (isCurrentDialogTarget) {
val db = RosettaDatabase.getDatabase(context) withContext(Dispatchers.Main) {
val dialogDao = db.dialogDao() updateMessageStatus(messageId, MessageStatus.SENT)
if (isSavedMessages) { }
dialogDao.updateSavedMessagesDialogFromMessages(sender)
} else {
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
} }
} catch (e: Exception) { } refreshTargetDialog()
} catch (e: Exception) {
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
}
} }
} }

View File

@@ -273,6 +273,9 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -443,10 +446,6 @@ fun ChatsListScreen(
insetsController.isAppearanceLightStatusBars = false insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
// Navigation bar
com.rosetta.messenger.ui.utils.NavigationModeUtils
.applyNavigationBarVisibility(insetsController, context, isDarkTheme)
onDispose { } onDispose { }
} }
@@ -754,7 +753,10 @@ fun ChatsListScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.onSizeChanged { rootSize = it } .onSizeChanged { rootSize = it }
.background(backgroundColor) .background(backgroundColor)
.navigationBarsPadding() .then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) { ) {
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -812,6 +814,15 @@ fun ChatsListScreen(
"rosetta", "rosetta",
ignoreCase = true ignoreCase = true
) )
val isFreddyOfficial =
accountName.equals(
"freddy",
ignoreCase = true
) ||
accountUsername.equals(
"freddy",
ignoreCase = true
)
// Avatar row with theme toggle // Avatar row with theme toggle
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -925,7 +936,7 @@ fun ChatsListScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White
) )
if (accountVerified > 0 || isRosettaOfficial) { if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer( Spacer(
modifier = modifier =
Modifier.width( Modifier.width(
@@ -935,7 +946,7 @@ fun ChatsListScreen(
VerifiedBadge( VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1, verified = if (accountVerified > 0) accountVerified else 1,
size = 15, size = 15,
badgeTint = PrimaryBlue badgeTint = if (isDarkTheme) Color.White else PrimaryBlue
) )
} }
} }
@@ -1230,7 +1241,14 @@ fun ChatsListScreen(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// FOOTER - Version + Update Banner // FOOTER - Version + Update Banner
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { Column(
modifier =
Modifier.fillMaxWidth()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
// Telegram-style update banner // Telegram-style update banner
val curUpdate = sduUpdateState val curUpdate = sduUpdateState
val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable || val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable ||
@@ -3886,7 +3904,10 @@ fun DialogItemContent(
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) || val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) || dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey) MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) { val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
displayName.equals("freddy", ignoreCase = true)
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1, verified = if (dialog.verified > 0) dialog.verified else 1,

View File

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

View File

@@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview(
val cached = ImageBitmapCache.get(cacheKey) val cached = ImageBitmapCache.get(cacheKey)
if (cached != null) { imageBitmap = cached; return@LaunchedEffect } if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
// 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл
if (attachment.localUri.isNotEmpty()) {
val localBitmap =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver
.openInputStream(
android.net.Uri.parse(
attachment.localUri
)
)
?.use { input ->
BitmapFactory.decodeStream(input)
}
}.getOrNull()
}
if (localBitmap != null) {
imageBitmap = localBitmap
ImageBitmapCache.put(cacheKey, localBitmap)
return@LaunchedEffect
}
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Try local file cache first // Try local file cache first
try { try {

View File

@@ -829,7 +829,7 @@ fun ImageEditorScreen(
* Telegram-style toolbar - icons only, no labels * Telegram-style toolbar - icons only, no labels
*/ */
@Composable @Composable
private fun TelegramToolbar( internal fun TelegramToolbar(
currentTool: EditorTool, currentTool: EditorTool,
showCaptionInput: Boolean, showCaptionInput: Boolean,
isSaving: Boolean, isSaving: Boolean,
@@ -958,7 +958,7 @@ private fun TelegramToolButton(
* Telegram-style color picker with brush size * Telegram-style color picker with brush size
*/ */
@Composable @Composable
private fun TelegramColorPicker( internal fun TelegramColorPicker(
selectedColor: Color, selectedColor: Color,
brushSize: Float, brushSize: Float,
onColorSelected: (Color) -> Unit, onColorSelected: (Color) -> Unit,
@@ -1044,7 +1044,7 @@ private fun TelegramColorPicker(
* Telegram-style rotate bar * Telegram-style rotate bar
*/ */
@Composable @Composable
private fun TelegramRotateBar( internal fun TelegramRotateBar(
onRotateLeft: () -> Unit, onRotateLeft: () -> Unit,
onRotateRight: () -> Unit, onRotateRight: () -> Unit,
onFlipHorizontal: () -> Unit, onFlipHorizontal: () -> Unit,
@@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld(
} }
/** Save edited image synchronously (with all editor changes). */ /** Save edited image synchronously (with all editor changes). */
private suspend fun saveEditedImageSync( internal suspend fun saveEditedImageSync(
context: Context, context: Context,
photoEditor: PhotoEditor?, photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?, photoEditorView: PhotoEditorView?,
@@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair<Int, In
} }
/** Launch UCrop activity */ /** Launch UCrop activity */
private fun launchCrop( internal fun launchCrop(
context: Context, context: Context,
sourceUri: Uri, sourceUri: Uri,
launcher: androidx.activity.result.ActivityResultLauncher<Intent> launcher: androidx.activity.result.ActivityResultLauncher<Intent>

View File

@@ -82,6 +82,13 @@ import kotlin.math.roundToInt
private const val TAG = "MediaPickerBottomSheet" private const val TAG = "MediaPickerBottomSheet"
private const val ALL_MEDIA_ALBUM_ID = 0L private const val ALL_MEDIA_ALBUM_ID = 0L
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/** /**
* Media item from gallery * Media item from gallery
*/ */
@@ -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

View File

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

View File

@@ -104,7 +104,8 @@ fun MessageInputBar(
mentionCandidates: List<MentionCandidate> = emptyList(), mentionCandidates: List<MentionCandidate> = emptyList(),
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
inputFocusTrigger: Int = 0, inputFocusTrigger: Int = 0,
suppressKeyboard: Boolean = false suppressKeyboard: Boolean = false,
hasNativeNavigationBar: Boolean = true
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val liveReplyMessages = val liveReplyMessages =
@@ -367,7 +368,10 @@ fun MessageInputBar(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp) .padding(horizontal = 12.dp, vertical = 16.dp)
.padding(bottom = 20.dp) .padding(bottom = 20.dp)
.navigationBarsPadding(), .then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@@ -398,7 +402,10 @@ fun MessageInputBar(
) )
) )
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible val shouldAddNavBarPadding =
hasNativeNavigationBar &&
!isKeyboardVisible &&
!coordinator.isEmojiBoxVisible
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -45,8 +45,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.ui.theme.* import com.rosetta.messenger.ui.theme.*
@@ -124,8 +122,11 @@ fun OnboardingScreen(
// Animate navigation bar color starting at 80% of wave animation // Animate navigation bar color starting at 80% of wave animation
val view = LocalView.current val view = LocalView.current
val isGestureNavigation = remember(view.context) {
NavigationModeUtils.isGestureNavigation(view.context)
}
LaunchedEffect(isTransitioning, transitionProgress) { LaunchedEffect(isTransitioning, transitionProgress) {
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
// Map 0.8-1.0 to 0-1 for smooth interpolation // Map 0.8-1.0 to 0-1 for smooth interpolation
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
@@ -163,7 +164,10 @@ fun OnboardingScreen(
// Navigation bar: показываем только если есть нативные кнопки // Navigation bar: показываем только если есть нативные кнопки
NavigationModeUtils.applyNavigationBarVisibility( NavigationModeUtils.applyNavigationBarVisibility(
insetsController, view.context, isDarkTheme window = window,
insetsController = insetsController,
context = view.context,
isDarkTheme = isDarkTheme
) )
} }
} }
@@ -173,10 +177,12 @@ fun OnboardingScreen(
if (!view.isInEditMode) { if (!view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
window.navigationBarColor = NavigationModeUtils.applyNavigationBarVisibility(
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() window = window,
insetsController.show(WindowInsetsCompat.Type.navigationBars()) insetsController = insetsController,
insetsController.isAppearanceLightNavigationBars = !isDarkTheme context = view.context,
isDarkTheme = isDarkTheme
)
} }
} }

View File

@@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader(
val isRosettaOfficial = val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) || name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true) username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial) { if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 1, verified = if (verified > 0) verified else 1,

View File

@@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader(
val isRosettaOfficial = val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) || name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true) username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion // Expansion fraction — computed early so gradient can fade during expansion
@@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader(
modifier = Modifier.widthIn(max = 220.dp), modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0 || isRosettaOfficial) { if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge( VerifiedBadge(
verified = if (verified > 0) verified else 2, verified = if (verified > 0) verified else 2,

View File

@@ -68,15 +68,21 @@ fun RosettaAndroidTheme(
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { DisposableEffect(darkTheme, view, context) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
// Make status bar transparent for wave animation overlay // Make status bar transparent for wave animation overlay
window.statusBarColor = AndroidColor.TRANSPARENT window.statusBarColor = AndroidColor.TRANSPARENT
// Note: isAppearanceLightStatusBars is managed per-screen, not globally // Note: isAppearanceLightStatusBars is managed per-screen, not globally
// Navigation bar: показываем только если есть нативные кнопки NavigationModeUtils.applyNavigationBarVisibility(
NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme) window = window,
insetsController = insetsController,
context = context,
isDarkTheme = darkTheme
)
onDispose { }
} }
} }

View File

@@ -1,6 +1,10 @@
package com.rosetta.messenger.ui.utils package com.rosetta.messenger.ui.utils
import android.content.Context import android.content.Context
import android.graphics.Color
import android.os.Build
import android.view.View
import android.view.Window
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -58,12 +62,42 @@ object NavigationModeUtils {
* Показывает navigation bar на всех устройствах. * Показывает navigation bar на всех устройствах.
*/ */
fun applyNavigationBarVisibility( fun applyNavigationBarVisibility(
window: Window,
insetsController: WindowInsetsControllerCompat, insetsController: WindowInsetsControllerCompat,
context: Context, context: Context,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val gestureNavigation = isGestureNavigation(context)
val decorView = window.decorView
insetsController.show(WindowInsetsCompat.Type.navigationBars()) insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightNavigationBars = !isDarkTheme if (gestureNavigation) {
// In gesture mode we keep a transparent nav bar and fixed white handle.
// This avoids the "jump" effect during theme switching.
window.navigationBarColor = Color.TRANSPARENT
insetsController.isAppearanceLightNavigationBars = false
val newFlags =
decorView.systemUiVisibility or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (newFlags != decorView.systemUiVisibility) {
decorView.systemUiVisibility = newFlags
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
} else {
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
val newFlags =
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
if (newFlags != decorView.systemUiVisibility) {
decorView.systemUiVisibility = newFlags
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
}
} }
} }