Переработан fullscreen фото из медиапикера под поведение Telegram

This commit is contained in:
2026-03-13 12:16:20 +07:00
parent 7641fbc560
commit aa096e2e87
4 changed files with 325 additions and 87 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,8 @@ 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) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
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
}
)
}
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(

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

View File

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

View File

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