From aa096e2e87b61deb6e31da49ea55d8b60218a6f9 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 13 Mar 2026 12:16:20 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BD=20fullscreen=20=D1=84=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B8=D0=B7=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D0=BA=D0=B5=D1=80=D0=B0=20=D0=BF=D0=BE=D0=B4=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 49 +++- .../ui/chats/attach/ChatAttachAlert.kt | 54 ++-- .../components/MediaPickerBottomSheet.kt | 58 ++-- .../components/SimpleFullscreenPhotoViewer.kt | 251 ++++++++++++++++++ 4 files changed, 325 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 5aeee90..12f3706 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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,8 @@ fun ChatDetailScreen( var imageViewerInitialIndex by remember { mutableStateOf(0) } var imageViewerSourceBounds by remember { mutableStateOf(null) } var imageViewerImages by remember { mutableStateOf>(emptyList()) } + var simplePickerPreviewUri by remember { mutableStateOf(null) } + var simplePickerPreviewSourceThumb by remember { mutableStateOf(null) } // 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме if (!view.isInEditMode) { @@ -364,7 +370,8 @@ fun ChatDetailScreen( showEmojiPicker, pendingCameraPhotoUri, pendingGalleryImages, - showInAppCamera + showInAppCamera, + simplePickerPreviewUri ) { derivedStateOf { showImageViewer || @@ -372,7 +379,8 @@ fun ChatDetailScreen( showEmojiPicker || pendingCameraPhotoUri != null || pendingGalleryImages.isNotEmpty() || - showInAppCamera + showInAppCamera || + simplePickerPreviewUri != null } } @@ -1801,7 +1809,10 @@ fun ChatDetailScreen( bottom = 16.dp ) - .navigationBarsPadding() + .then( + if (hasNativeNavigationBar) Modifier.navigationBarsPadding() + else Modifier + ) .graphicsLayer { scaleX = buttonScale @@ -2168,7 +2179,9 @@ fun ChatDetailScreen( inputFocusTrigger = inputFocusTrigger, suppressKeyboard = - showInAppCamera + showInAppCamera, + hasNativeNavigationBar = + hasNativeNavigationBar ) } } @@ -3005,7 +3018,13 @@ fun ChatDetailScreen( onAvatarClick = { viewModel.sendAvatarMessage() }, - recipientName = user.title + recipientName = user.title, + onPhotoPreviewRequested = { uri, sourceThumb -> + hideInputOverlays() + showMediaPicker = false + simplePickerPreviewSourceThumb = sourceThumb + simplePickerPreviewUri = uri + } ) } else { MediaPickerBottomSheet( @@ -3049,7 +3068,13 @@ fun ChatDetailScreen( onAvatarClick = { viewModel.sendAvatarMessage() }, - recipientName = user.title + recipientName = user.title, + onPhotoPreviewRequested = { uri, sourceThumb -> + hideInputOverlays() + showMediaPicker = false + simplePickerPreviewSourceThumb = sourceThumb + simplePickerPreviewUri = uri + } ) } } // Закрытие Box wrapper для Scaffold content @@ -3314,6 +3339,18 @@ fun ChatDetailScreen( } // Закрытие Scaffold content lambda + simplePickerPreviewUri?.let { previewUri -> + SimpleFullscreenPhotoOverlay( + imageUri = previewUri, + sourceThumbnail = simplePickerPreviewSourceThumb, + modifier = Modifier.fillMaxSize().zIndex(100f), + onDismiss = { + simplePickerPreviewUri = null + simplePickerPreviewSourceThumb = null + } + ) + } + // � Image Viewer Overlay — FULLSCREEN поверх Scaffold if (showImageViewer && imageViewerImages.isNotEmpty()) { ImageViewerScreen( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 302276e..3ccb8c5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -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 @@ -146,6 +147,7 @@ fun ChatAttachAlert( currentUserPublicKey: String = "", maxSelection: Int = 10, recipientName: String? = null, + onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null, viewModel: AttachAlertViewModel = viewModel() ) { val context = LocalContext.current @@ -679,8 +681,8 @@ 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) -> @@ -710,7 +712,7 @@ fun ChatAttachAlert( // POPUP RENDERING // ═══════════════════════════════════════════════════════════ - if (shouldShow) { + if (shouldShow && state.editingItem == null) { Popup( alignment = Alignment.TopStart, onDismissRequest = { @@ -976,8 +978,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) } @@ -1173,45 +1180,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 + } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index d9e9637..6109f30 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -125,7 +125,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,8 +568,8 @@ 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) } @@ -593,7 +594,7 @@ fun MediaPickerBottomSheet( } // Используем Popup для показа поверх клавиатуры - if (shouldShow) { + if (shouldShow && editingItem == null) { // BackHandler для закрытия по back BackHandler { if (isExpanded) { @@ -986,8 +987,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) @@ -1279,48 +1285,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 + } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt new file mode 100644 index 0000000..a170bfa --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt @@ -0,0 +1,251 @@ +package com.rosetta.messenger.ui.chats.components + +import android.graphics.Color as AndroidColor +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +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 coil.compose.AsyncImage +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: android.net.Uri, + onDismiss: () -> Unit, + sourceThumbnail: ThumbnailPosition? = null +) { + 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 + ) + } +} + +@Composable +fun SimpleFullscreenPhotoOverlay( + imageUri: android.net.Uri, + onDismiss: () -> Unit, + sourceThumbnail: ThumbnailPosition? = null, + modifier: Modifier = Modifier +) { + SimpleFullscreenPhotoContent( + imageUri = imageUri, + onDismiss = onDismiss, + sourceThumbnail = sourceThumbnail, + modifier = modifier + ) +} + +@Composable +private fun SimpleFullscreenPhotoContent( + imageUri: android.net.Uri, + onDismiss: () -> Unit, + sourceThumbnail: ThumbnailPosition? = null, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + var isClosing by remember { mutableStateOf(false) } + var screenSize by remember { mutableStateOf(IntSize.Zero) } + val progress = remember(imageUri, sourceThumbnail) { + Animatable(if (sourceThumbnail != null) 0f else 1f) + } + + LaunchedEffect(imageUri, sourceThumbnail) { + if (progress.value < 1f) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 230, easing = ViewerExpandEasing) + ) + } + } + + fun closeViewer() { + if (isClosing) return + isClosing = true + scope.launch { + progress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 210, easing = ViewerExpandEasing) + ) + onDismiss() + } + } + + 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 + ) + } + } + } + + Box( + modifier = + modifier.fillMaxSize() + .onSizeChanged { screenSize = it } + .background(Color.Black) + .pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageUri, + contentDescription = "Photo", + 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 + } + ), + contentScale = ContentScale.Crop + ) + } +}