Compare commits

...

2 Commits

14 changed files with 1287 additions and 242 deletions

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,768 @@
package com.rosetta.messenger.ui.chats.components
import android.app.Activity
import android.content.Context
import android.graphics.Color as AndroidColor
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.components.AppleEmojiEditTextView
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.yalantis.ucrop.UCrop
import ja.burhanrashid52.photoeditor.PhotoEditor
import ja.burhanrashid52.photoeditor.PhotoEditorView
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
private val ViewerExpandEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
private data class SimpleViewerTransform(
val scaleX: Float,
val scaleY: Float,
val translationX: Float,
val translationY: Float,
val cornerRadiusDp: Float
)
private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
return start + (stop - start) * fraction
}
private fun setupFullscreenWindow(window: Window?) {
window ?: return
WindowCompat.setDecorFitsSystemWindows(window, false)
window.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT
)
window.setBackgroundDrawable(ColorDrawable(AndroidColor.TRANSPARENT))
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.decorView.setPadding(0, 0, 0, 0)
val attrs = window.attributes
attrs.width = WindowManager.LayoutParams.MATCH_PARENT
attrs.height = WindowManager.LayoutParams.MATCH_PARENT
attrs.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes = attrs
val decorView = window.decorView
val telegramLikeFlags =
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (decorView.systemUiVisibility != telegramLikeFlags) {
decorView.systemUiVisibility = telegramLikeFlags
}
window.statusBarColor = AndroidColor.TRANSPARENT
window.navigationBarColor = AndroidColor.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isStatusBarContrastEnforced = false
window.isNavigationBarContrastEnforced = false
}
val controller = WindowCompat.getInsetsController(window, decorView)
controller.isAppearanceLightStatusBars = false
controller.isAppearanceLightNavigationBars = false
controller.show(WindowInsetsCompat.Type.statusBars())
controller.show(WindowInsetsCompat.Type.navigationBars())
window.setWindowAnimations(0)
}
@Composable
fun SimpleFullscreenPhotoViewer(
imageUri: Uri,
onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null,
showCaptionInput: Boolean = false,
caption: String = "",
onCaptionChange: ((String) -> Unit)? = null,
onSend: ((Uri, String) -> Unit)? = null,
isDarkTheme: Boolean = true
) {
Dialog(
onDismissRequest = onDismiss,
properties =
DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
val view = LocalView.current
SideEffect {
val dialogWindow = (view.parent as? DialogWindowProvider)?.window
setupFullscreenWindow(dialogWindow)
}
SimpleFullscreenPhotoContent(
imageUri = imageUri,
onDismiss = onDismiss,
sourceThumbnail = sourceThumbnail,
showCaptionInput = showCaptionInput,
caption = caption,
onCaptionChange = onCaptionChange,
onSend = onSend,
isDarkTheme = isDarkTheme
)
}
}
@Composable
fun SimpleFullscreenPhotoOverlay(
imageUri: Uri,
onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null,
modifier: Modifier = Modifier,
showCaptionInput: Boolean = false,
caption: String = "",
onCaptionChange: ((String) -> Unit)? = null,
onSend: ((Uri, String) -> Unit)? = null,
isDarkTheme: Boolean = true
) {
SimpleFullscreenPhotoContent(
imageUri = imageUri,
onDismiss = onDismiss,
sourceThumbnail = sourceThumbnail,
modifier = modifier,
showCaptionInput = showCaptionInput,
caption = caption,
onCaptionChange = onCaptionChange,
onSend = onSend,
isDarkTheme = isDarkTheme
)
}
@Composable
private fun SimpleFullscreenPhotoContent(
imageUri: Uri,
onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null,
modifier: Modifier = Modifier,
showCaptionInput: Boolean = false,
caption: String = "",
onCaptionChange: ((String) -> Unit)? = null,
onSend: ((Uri, String) -> Unit)? = null,
isDarkTheme: Boolean = true
) {
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
var isClosing by remember { mutableStateOf(false) }
var screenSize by remember { mutableStateOf(IntSize.Zero) }
var showEmojiPicker by remember { mutableStateOf(false) }
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
var lastToggleTime by remember { mutableLongStateOf(0L) }
var isKeyboardVisible by remember { mutableStateOf(false) }
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
var localCaption by remember(imageUri) { mutableStateOf("") }
var currentImageUri by remember(imageUri) { mutableStateOf(imageUri) }
var currentTool by remember { mutableStateOf(EditorTool.NONE) }
var selectedColor by remember { mutableStateOf(Color.White) }
var brushSize by remember { mutableStateOf(12f) }
var showColorPicker by remember { mutableStateOf(false) }
var isEraserActive by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
var photoEditor by remember { mutableStateOf<PhotoEditor?>(null) }
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
var hasDrawingEdits by remember { mutableStateOf(false) }
var rotationAngle by remember { mutableStateOf(0f) }
var isFlippedHorizontally by remember { mutableStateOf(false) }
var isFlippedVertically by remember { mutableStateOf(false) }
val progress = remember(imageUri, sourceThumbnail) {
Animatable(if (sourceThumbnail != null) 0f else 1f)
}
val coordinator = rememberKeyboardTransitionCoordinator()
val imeInsets = WindowInsets.ime
val toggleCooldownMs = 500L
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { data ->
UCrop.getOutput(data)?.let { croppedUri ->
currentImageUri = croppedUri
rotationAngle = 0f
isFlippedHorizontally = false
isFlippedVertically = false
currentTool = EditorTool.NONE
showColorPicker = false
isEraserActive = false
}
}
}
}
val captionText = if (onCaptionChange != null) caption else localCaption
val updateCaption: (String) -> Unit = { value ->
if (onCaptionChange != null) {
onCaptionChange(value)
} else {
localCaption = value
}
}
LaunchedEffect(imageUri, sourceThumbnail) {
localCaption = caption
if (progress.value < 1f) {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 230, easing = ViewerExpandEasing)
)
}
}
LaunchedEffect(showCaptionInput) {
if (!showCaptionInput) return@LaunchedEffect
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
isKeyboardVisible = currentImeHeight > 50.dp
coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
}
}
}
LaunchedEffect(showCaptionInput) {
if (showCaptionInput) {
KeyboardHeightProvider.getSavedKeyboardHeight(context)
}
}
LaunchedEffect(showCaptionInput, isKeyboardVisible, showEmojiPicker, lastStableKeyboardHeight) {
if (!showCaptionInput) return@LaunchedEffect
if (isKeyboardVisible && !showEmojiPicker) {
delay(350)
if (isKeyboardVisible && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) {
val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() }
KeyboardHeightProvider.saveKeyboardHeight(context, heightPx)
}
}
}
fun hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
fun closeViewer() {
if (isClosing) return
isClosing = true
showEmojiPicker = false
hideKeyboard()
focusManager.clearFocus(force = true)
scope.launch {
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 210, easing = ViewerExpandEasing)
)
onDismiss()
}
}
fun toggleEmojiPicker() {
val now = System.currentTimeMillis()
if (now - lastToggleTime < toggleCooldownMs) {
return
}
lastToggleTime = now
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (coordinator.isEmojiVisible) {
coordinator.requestShowKeyboard(
showKeyboard = {
editTextView?.let { editText ->
editText.requestFocus()
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
},
hideEmoji = { showEmojiPicker = false }
)
} else {
coordinator.requestShowEmoji(
hideKeyboard = {
imm.hideSoftInputFromWindow(view.windowToken, 0)
},
showEmoji = { showEmojiPicker = true }
)
}
}
BackHandler { closeViewer() }
val transform by remember(sourceThumbnail, screenSize, progress.value) {
derivedStateOf {
val p = progress.value
if (sourceThumbnail != null && screenSize != IntSize.Zero) {
val screenW = screenSize.width.toFloat().coerceAtLeast(1f)
val screenH = screenSize.height.toFloat().coerceAtLeast(1f)
val srcW = sourceThumbnail.width.coerceAtLeast(1f)
val srcH = sourceThumbnail.height.coerceAtLeast(1f)
val sourceScaleX = srcW / screenW
val sourceScaleY = srcH / screenH
val targetCenterX = screenW / 2f
val targetCenterY = screenH / 2f
val sourceCenterX = sourceThumbnail.x + srcW / 2f
val sourceCenterY = sourceThumbnail.y + srcH / 2f
SimpleViewerTransform(
scaleX = lerpFloat(sourceScaleX, 1f, p),
scaleY = lerpFloat(sourceScaleY, 1f, p),
translationX = lerpFloat(sourceCenterX - targetCenterX, 0f, p),
translationY = lerpFloat(sourceCenterY - targetCenterY, 0f, p),
cornerRadiusDp = lerpFloat(sourceThumbnail.cornerRadius, 0f, p)
)
} else {
SimpleViewerTransform(
scaleX = lerpFloat(0.94f, 1f, p),
scaleY = lerpFloat(0.94f, 1f, p),
translationX = 0f,
translationY = 0f,
cornerRadiusDp = 0f
)
}
}
}
val tapToDismissModifier =
if (!showCaptionInput) {
Modifier.pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) }
} else {
Modifier
}
Box(
modifier =
modifier.fillMaxSize()
.onSizeChanged { screenSize = it }
.background(Color.Black)
.then(tapToDismissModifier),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
PhotoEditorView(ctx).apply {
photoEditorView = this
setPadding(0, 0, 0, 0)
setBackgroundColor(android.graphics.Color.BLACK)
source.apply {
scaleType = ImageView.ScaleType.CENTER_CROP
adjustViewBounds = false
setPadding(0, 0, 0, 0)
setImageURI(currentImageUri)
}
photoEditor = PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
}
},
update = { editorView ->
if (editorView.source.tag != currentImageUri) {
editorView.source.setImageURI(currentImageUri)
editorView.source.tag = currentImageUri
}
editorView.source.rotation = rotationAngle
editorView.source.scaleX = if (isFlippedHorizontally) -1f else 1f
editorView.source.scaleY = if (isFlippedVertically) -1f else 1f
},
modifier =
Modifier.fillMaxSize()
.graphicsLayer {
scaleX = transform.scaleX
scaleY = transform.scaleY
translationX = transform.translationX
translationY = transform.translationY
}
.then(
if (transform.cornerRadiusDp > 0f) {
Modifier.clip(RoundedCornerShape(transform.cornerRadiusDp.dp))
} else {
Modifier
}
)
)
if (showCaptionInput) {
Box(
modifier =
Modifier.fillMaxWidth()
.align(Alignment.TopCenter)
.background(
Brush.verticalGradient(
colors =
listOf(
Color.Black.copy(alpha = 0.55f),
Color.Transparent
)
)
)
.statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
IconButton(
onClick = { closeViewer() },
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
painter = TelegramIcons.Close,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
AnimatedVisibility(
visible = currentTool == EditorTool.DRAW && showColorPicker,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier =
Modifier.align(Alignment.BottomCenter)
.padding(bottom = 132.dp)
) {
TelegramColorPicker(
selectedColor = selectedColor,
brushSize = brushSize,
onColorSelected = { color ->
selectedColor = color
photoEditor?.brushColor = color.toArgb()
},
onBrushSizeChanged = { size ->
brushSize = size
photoEditor?.brushSize = size
}
)
}
AnimatedVisibility(
visible = currentTool == EditorTool.ROTATE,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier =
Modifier.align(Alignment.BottomCenter)
.padding(bottom = 132.dp)
) {
TelegramRotateBar(
onRotateLeft = { rotationAngle = (rotationAngle - 90f) % 360f },
onRotateRight = { rotationAngle = (rotationAngle + 90f) % 360f },
onFlipHorizontal = { isFlippedHorizontally = !isFlippedHorizontally },
onFlipVertical = { isFlippedVertically = !isFlippedVertically }
)
}
val shouldUseImePadding = !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
Column(
modifier =
Modifier.fillMaxWidth()
.align(Alignment.BottomCenter)
.then(if (shouldUseImePadding) Modifier.imePadding() else Modifier)
) {
AnimatedVisibility(
visible = !isKeyboardVisible && !showEmojiPicker && !coordinator.isEmojiBoxVisible,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it }
) {
Box(
modifier =
Modifier.fillMaxWidth()
.background(
Brush.verticalGradient(
colors =
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.6f)
)
)
)
) {
TelegramToolbar(
currentTool = currentTool,
showCaptionInput = true,
isSaving = isSaving,
isEraserActive = isEraserActive,
onCropClick = {
currentTool = EditorTool.NONE
showColorPicker = false
isEraserActive = false
photoEditor?.setBrushDrawingMode(false)
launchCrop(context, currentImageUri, cropLauncher)
},
onRotateClick = {
currentTool =
if (currentTool == EditorTool.ROTATE) EditorTool.NONE
else EditorTool.ROTATE
showColorPicker = false
isEraserActive = false
photoEditor?.setBrushDrawingMode(false)
},
onDrawClick = {
if (currentTool == EditorTool.DRAW) {
if (isEraserActive) {
isEraserActive = false
photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb()
photoEditor?.brushSize = brushSize
} else {
showColorPicker = !showColorPicker
}
} else {
currentTool = EditorTool.DRAW
hasDrawingEdits = true
isEraserActive = false
photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb()
photoEditor?.brushSize = brushSize
showColorPicker = true
}
},
onEraserClick = {
isEraserActive = !isEraserActive
if (isEraserActive) {
photoEditor?.brushEraser()
} else {
photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb()
photoEditor?.brushSize = brushSize
}
},
onDrawDoneClick = {
currentTool = EditorTool.NONE
showColorPicker = false
isEraserActive = false
photoEditor?.setBrushDrawingMode(false)
},
onDoneClick = {}
)
}
}
Box(
modifier =
Modifier.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.75f))
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom =
if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 10.dp
else 16.dp
)
.then(
if (shouldAddNavBarPadding) Modifier.navigationBarsPadding()
else Modifier
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(
onClick = { toggleEmojiPicker() },
modifier = Modifier.size(32.dp)
) {
Crossfade(
targetState = showEmojiPicker,
animationSpec = tween(150),
label = "simpleViewerEmojiToggle"
) { isEmoji ->
Icon(
painter = if (isEmoji) TelegramIcons.Keyboard else TelegramIcons.Smile,
contentDescription = if (isEmoji) "Keyboard" else "Emoji",
tint = Color.White.copy(alpha = 0.72f),
modifier = Modifier.size(26.dp)
)
}
}
Box(
modifier =
Modifier.weight(1f)
.heightIn(min = 24.dp, max = 100.dp)
) {
AppleEmojiTextField(
value = captionText,
onValueChange = updateCaption,
textColor = Color.White,
textSize = 16f,
hint = "Add a caption...",
hintColor = Color.White.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
requestFocus = false,
onViewCreated = { textView -> editTextView = textView },
onFocusChanged = { hasFocus ->
if (hasFocus && showEmojiPicker) {
toggleEmojiPicker()
}
}
)
}
Box(
modifier =
Modifier.size(44.dp)
.shadow(
elevation = 4.dp,
shape = CircleShape,
clip = false
)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) {
if (isSaving || isClosing) return@clickable
showEmojiPicker = false
hideKeyboard()
focusManager.clearFocus(force = true)
scope.launch {
isSaving = true
val savedUri =
saveEditedImageSync(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = currentImageUri,
hasDrawingEdits = hasDrawingEdits
)
isSaving = false
val finalUri = savedUri ?: currentImageUri
if (onSend != null) {
onSend(finalUri, captionText)
} else {
closeViewer()
}
}
},
contentAlignment = Alignment.Center
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
painter = TelegramIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier =
Modifier.size(24.dp)
.offset(x = 1.dp)
)
}
}
}
}
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji -> updateCaption(captionText + emoji) },
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}

View File

@@ -104,7 +104,8 @@ fun MessageInputBar(
mentionCandidates: List<MentionCandidate> = emptyList(),
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

View File

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

View File

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

View File

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

View File

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

View File

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