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 992aacf..ec7e0a9 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 @@ -84,6 +84,8 @@ import android.provider.MediaStore import androidx.core.content.FileProvider import java.io.File import kotlinx.coroutines.delay +import android.app.Activity +import androidx.core.view.WindowCompat import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -128,6 +130,9 @@ fun ChatDetailScreen( val coordinator = remember { KeyboardTransitionCoordinator() } val view = LocalView.current + // 🎨 Window reference для управления статус баром + val window = remember { (view.context as? Activity)?.window } + // 🔥 Focus state for input val inputFocusRequester = remember { FocusRequester() } @@ -189,6 +194,24 @@ fun ChatDetailScreen( var imageViewerInitialIndex by remember { mutableStateOf(0) } var imageViewerSourceBounds by remember { mutableStateOf(null) } + // 🎨 Управление статус баром - чёрный при просмотре фото + DisposableEffect(isDarkTheme, showImageViewer) { + val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } + + if (showImageViewer) { + // 📸 При просмотре фото - чёрный статус бар + window?.statusBarColor = 0xFF000000.toInt() + insetsController?.isAppearanceLightStatusBars = false + } else { + // Обычный режим - цвет хедера + val headerColor = if (isDarkTheme) 0xFF212121.toInt() else 0xFFFFFFFF.toInt() + window?.statusBarColor = headerColor + insetsController?.isAppearanceLightStatusBars = !isDarkTheme + } + + onDispose { } + } + // 📷 Camera: URI для сохранения фото var cameraImageUri by remember { mutableStateOf(null) } @@ -532,6 +555,8 @@ fun ChatDetailScreen( else Color.White } else headerBackground ) + // 🎨 statusBarsPadding ПОСЛЕ background = хедер начинается под статус баром + .statusBarsPadding() ) { // Контент хедера с Crossfade для плавной смены - ускоренная // анимация @@ -545,7 +570,6 @@ fun ChatDetailScreen( Row( modifier = Modifier.fillMaxWidth() - .statusBarsPadding() .height(56.dp) .padding( horizontal = @@ -723,7 +747,6 @@ fun ChatDetailScreen( Row( modifier = Modifier.fillMaxWidth() - .statusBarsPadding() .height(56.dp) .padding( horizontal = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 36584a1..d279c33 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -65,6 +65,7 @@ import ja.burhanrashid52.photoeditor.PhotoEditorView import ja.burhanrashid52.photoeditor.SaveSettings import java.io.File import kotlin.coroutines.resume +import androidx.core.view.WindowCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -162,15 +163,24 @@ fun ImageEditorScreen( DisposableEffect(Unit) { val originalStatusBarColor = window?.statusBarColor ?: 0 val originalNavigationBarColor = window?.navigationBarColor ?: 0 - - // Устанавливаем черный цвет + + // 🔥 Сохраняем оригинальное состояние иконок статус бара + val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } + val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false + val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false + + // Устанавливаем черный цвет и светлые иконки window?.statusBarColor = android.graphics.Color.BLACK window?.navigationBarColor = android.graphics.Color.BLACK - + insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне + insetsController?.isAppearanceLightNavigationBars = false + onDispose { - // Восстанавливаем оригинальные цвета + // Восстанавливаем оригинальные цвета и состояние иконок window?.statusBarColor = originalStatusBarColor window?.navigationBarColor = originalNavigationBarColor + insetsController?.isAppearanceLightStatusBars = originalLightStatusBars + insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars } } @@ -586,10 +596,10 @@ fun ImageEditorScreen( Column( modifier = Modifier .fillMaxWidth() - // 🔥 imePadding СНАЧАЛА - поднимает над клавиатурой - .then(if (shouldUseImePadding) Modifier.imePadding() else Modifier) - // 🔥 Затем добавляем отступ для toolbar когда клавиатура закрыта + // 🔥 Отступ для toolbar ТОЛЬКО когда клавиатура/emoji закрыты .padding(bottom = bottomPaddingForCaption) + // 🔥 imePadding поднимает над клавиатурой (применяется только когда emoji не показан) + .then(if (shouldUseImePadding) Modifier.imePadding() else Modifier) ) { TelegramCaptionBar( caption = caption, @@ -1414,6 +1424,33 @@ fun MultiImageEditorScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val view = LocalView.current + + // 🎨 Черный статус бар и навигационный бар для редактора + val window = remember { (view.context as? Activity)?.window } + DisposableEffect(Unit) { + val originalStatusBarColor = window?.statusBarColor ?: 0 + val originalNavigationBarColor = window?.navigationBarColor ?: 0 + + // 🔥 Сохраняем оригинальное состояние иконок статус бара + val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } + val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false + val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false + + // Устанавливаем черный цвет и светлые иконки + window?.statusBarColor = android.graphics.Color.BLACK + window?.navigationBarColor = android.graphics.Color.BLACK + insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне + insetsController?.isAppearanceLightNavigationBars = false + + onDispose { + // Восстанавливаем оригинальные цвета и состояние иконок + window?.statusBarColor = originalStatusBarColor + window?.navigationBarColor = originalNavigationBarColor + insetsController?.isAppearanceLightStatusBars = originalLightStatusBars + insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars + } + } val imagesWithCaptions = remember { mutableStateListOf().apply { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index 8d14370..9365873 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -155,23 +155,22 @@ fun ImageViewerScreen( } } - // Close with animation + // Close with animation - красиво возвращаемся к пузырьку fun closeWithAnimation() { if (isClosing) return isClosing = true - if (sourceBounds != null) { - scope.launch { + scope.launch { + if (sourceBounds != null) { + // Плавно возвращаемся к исходному положению пузырька animationProgress.animateTo( targetValue = 0f, animationSpec = tween( - durationMillis = 200, - easing = TelegramEasing + durationMillis = 280, + easing = FastOutSlowInEasing ) ) - onDismiss() } - } else { onDismiss() } } @@ -192,17 +191,61 @@ fun ImageViewerScreen( // UI visibility state var showControls by remember { mutableStateOf(true) } - // Drag to dismiss - var offsetY by remember { mutableStateOf(0f) } + // ═══════════════════════════════════════════════════════════ + // ═══════════════════════════════════════════════════════════ + // 🎬 ПРОСТОЙ FADE-OUT при свайпе + // ═══════════════════════════════════════════════════════════ + var dragOffsetY by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } - val dismissThreshold = 200f + var dragVelocity by remember { mutableFloatStateOf(0f) } + val dismissThreshold = 100f + val velocityThreshold = 500f + + // Анимированные значения + val animatedOffsetY = remember { Animatable(0f) } + val dismissAlpha = remember { Animatable(1f) } + + // Синхронизируем drag с анимацией + LaunchedEffect(isDragging, dragOffsetY) { + if (isDragging) { + animatedOffsetY.snapTo(dragOffsetY) + } + } - // Animated background alpha based on animation progress and drag - val baseAlpha = animationProgress.value - val dragAlpha = if (isDragging) { - (1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f) - } else 1f - val backgroundAlpha = baseAlpha * dragAlpha + // 🎬 Простой fade-out dismiss + fun smoothDismiss() { + if (isClosing) return + isClosing = true + + scope.launch { + // Плавно исчезаем - более длинная анимация + dismissAlpha.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 350, + easing = LinearEasing + ) + ) + onDismiss() + } + } + + // Плавный возврат на место + fun snapBack() { + scope.launch { + animatedOffsetY.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ) + ) + } + } + + // Alpha на основе drag прогресса + val dismissProgress = (animatedOffsetY.value.absoluteValue / 300f).coerceIn(0f, 1f) + val backgroundAlpha = animationProgress.value * dismissAlpha.value * (1f - dismissProgress * 0.5f) // Current image info val currentImage = images.getOrNull(pagerState.currentPage) @@ -212,37 +255,6 @@ fun ImageViewerScreen( closeWithAnimation() } - // ═══════════════════════════════════════════════════════════ - // 🎬 CALCULATE ANIMATED TRANSFORMS (Telegram style) - // ═══════════════════════════════════════════════════════════ - val progress = animationProgress.value - - // Calculate interpolated values for shared element transition - val animatedScale: Float - val animatedTranslationX: Float - val animatedTranslationY: Float - val animatedCornerRadius: Float - - if (sourceBounds != null && screenSize.width > 0 && screenSize.height > 0) { - // Source state (thumbnail in chat) - val sourceScale = sourceBounds.width / screenSize.width.toFloat() - val sourceCenterX = sourceBounds.left + sourceBounds.width / 2f - val sourceCenterY = sourceBounds.top + sourceBounds.height / 2f - val screenCenterX = screenSize.width / 2f - val screenCenterY = screenSize.height / 2f - - // Interpolate between source and fullscreen - animatedScale = sourceScale + (1f - sourceScale) * progress - animatedTranslationX = (sourceCenterX - screenCenterX) * (1f - progress) - animatedTranslationY = (sourceCenterY - screenCenterY) * (1f - progress) - animatedCornerRadius = sourceBounds.cornerRadius * (1f - progress) - } else { - animatedScale = 1f - animatedTranslationX = 0f - animatedTranslationY = 0f - animatedCornerRadius = 0f - } - Box( modifier = Modifier .fillMaxSize() @@ -250,63 +262,86 @@ fun ImageViewerScreen( .background(Color.Black.copy(alpha = backgroundAlpha)) ) { // ═══════════════════════════════════════════════════════════ - // 📸 HORIZONTAL PAGER with shared element animation + // 📸 HORIZONTAL PAGER с Telegram-style эффектом наслаивания // ═══════════════════════════════════════════════════════════ HorizontalPager( state = pagerState, modifier = Modifier .fillMaxSize() .graphicsLayer { - // Apply Telegram-style shared element transform - scaleX = animatedScale - scaleY = animatedScale - translationX = animatedTranslationX - translationY = animatedTranslationY + offsetY - // Clip with animated corner radius - clip = animatedCornerRadius > 0f - shape = if (animatedCornerRadius > 0f) { - RoundedCornerShape(animatedCornerRadius.dp) - } else { - RectangleShape - } + alpha = dismissAlpha.value + translationY = animatedOffsetY.value }, + pageSpacing = 30.dp, // Telegram: dp(30) между фото key = { images[it].attachmentId } ) { page -> val image = images[page] + + // 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется + val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + val isRightPage = pageOffset < 0 + + // Scale эффект только для правой страницы (1.0 → 0.7) + val scaleFactor = if (isRightPage) { + val progress = (-pageOffset).coerceIn(0f, 1f) + 1f - (progress * 0.3f) + } else 1f + + // Alpha для правой страницы + val pageAlpha = if (isRightPage) { + 1f - ((-pageOffset).coerceIn(0f, 1f) * 0.3f) + } else 1f - ZoomableImage( + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scaleFactor + scaleY = scaleFactor + alpha = pageAlpha + } + ) { + ZoomableImage( image = image, privateKey = privateKey, onTap = { showControls = !showControls }, - onVerticalDrag = { dragAmount -> - offsetY += dragAmount + onVerticalDrag = { dragAmount, velocity -> + dragOffsetY += dragAmount + dragVelocity = velocity isDragging = true }, onDragEnd = { isDragging = false - if (offsetY.absoluteValue > dismissThreshold) { - closeWithAnimation() + val shouldDismiss = dragOffsetY.absoluteValue > dismissThreshold || + dragVelocity.absoluteValue > velocityThreshold + if (shouldDismiss) { + smoothDismiss() } else { - offsetY = 0f + dragOffsetY = 0f + dragVelocity = 0f + snapBack() } } - ) + ) + } // Закрытие Box wrapper } // ═══════════════════════════════════════════════════════════ - // 🎛️ TOP BAR (показываем только когда анимация завершена) + // 🎛️ TOP BAR - Telegram-style (200ms, 24dp slide) // ═══════════════════════════════════════════════════════════ AnimatedVisibility( visible = showControls && animationState == 1 && !isClosing, - enter = fadeIn(tween(150)) + slideInVertically { -it }, - exit = fadeOut(tween(100)) + slideOutVertically { -it }, + enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) + + slideInVertically(tween(200, easing = FastOutSlowInEasing)) { -96 }, // ~24dp + exit = fadeOut(tween(200, easing = FastOutSlowInEasing)) + + slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { -96 }, modifier = Modifier.align(Alignment.TopCenter) ) { Box( modifier = Modifier .fillMaxWidth() .background( - Color.Black.copy(alpha = 0.6f) + Color.Black.copy(alpha = 0.5f) ) .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 8.dp) @@ -348,13 +383,15 @@ fun ImageViewerScreen( } // ═══════════════════════════════════════════════════════════ - // 📍 PAGE INDICATOR (если больше 1 фото, показываем когда анимация завершена) + // 📍 PAGE INDICATOR - Telegram-style (200ms, 24dp slide снизу) // ═══════════════════════════════════════════════════════════ if (images.size > 1) { AnimatedVisibility( visible = showControls && animationState == 1 && !isClosing, - enter = fadeIn(tween(150)), - exit = fadeOut(tween(100)), + enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) + + slideInVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, // +24dp снизу + exit = fadeOut(tween(200, easing = FastOutSlowInEasing)) + + slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 32.dp) @@ -406,14 +443,14 @@ fun ImageViewerScreen( * - Double tap to zoom 2.5x / reset * - Pinch to zoom 1x-5x * - Pan when zoomed - * - Vertical drag to dismiss when not zoomed + * - Vertical drag to dismiss when not zoomed (with velocity tracking) */ @Composable private fun ZoomableImage( image: ViewableImage, privateKey: String, onTap: () -> Unit, - onVerticalDrag: (Float) -> Unit = {}, + onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity onDragEnd: () -> Unit = {} ) { val context = LocalContext.current @@ -564,84 +601,100 @@ private fun ZoomableImage( } .pointerInput(Unit) { var isVerticalDragging = false + var lastDragTime = 0L + var lastDragY = 0f + var currentVelocity = 0f + val touchSlopValue = 20f // Минимальное смещение для определения направления - forEachGesture { - awaitPointerEventScope { - // Wait for first down - val down = awaitFirstDown(requireUnconsumed = false) + awaitEachGesture { + // Wait for first down + val down = awaitFirstDown(requireUnconsumed = false) + lastDragTime = System.currentTimeMillis() + lastDragY = down.position.y + currentVelocity = 0f + + var zoom = 1f + var pastTouchSlop = false + var lockedToDismiss = false + + do { + val event = awaitPointerEvent() + val canceled = event.changes.any { it.isConsumed } - var zoom = 1f - var pastTouchSlop = false - val touchSlop = viewConfiguration.touchSlop - var lockedToDismiss = false - - do { - val event = awaitPointerEvent() - val canceled = event.changes.any { it.isConsumed } + if (!canceled) { + val zoomChange = event.calculateZoom() + val panChange = event.calculatePan() - if (!canceled) { - val zoomChange = event.calculateZoom() - val panChange = event.calculatePan() + if (!pastTouchSlop) { + zoom *= zoomChange + val centroidSize = event.calculateCentroidSize(useCurrent = false) + val touchMoved = abs(panChange.x) > touchSlopValue || abs(panChange.y) > touchSlopValue + val zoomMotion = abs(1 - zoom) * centroidSize > touchSlopValue - if (!pastTouchSlop) { - zoom *= zoomChange - val centroidSize = event.calculateCentroidSize(useCurrent = false) - val touchMoved = abs(panChange.x) > touchSlop || abs(panChange.y) > touchSlop - val zoomMotion = abs(1 - zoom) * centroidSize > touchSlop + if (touchMoved || zoomMotion) { + pastTouchSlop = true - if (touchMoved || zoomMotion) { - pastTouchSlop = true - - // Decide: vertical dismiss or zoom/pan? - if (scale <= 1.05f && zoomChange == 1f && - abs(panChange.y) > abs(panChange.x) * 1.5f) { - lockedToDismiss = true - isVerticalDragging = true - } - } - } - - if (pastTouchSlop) { - if (lockedToDismiss) { - // Vertical drag for dismiss - onVerticalDrag(panChange.y) - event.changes.forEach { it.consume() } - } else { - // Zoom and pan - val newScale = (scale * zoomChange).coerceIn(minScale, maxScale) - - // Calculate max offsets based on zoom - val maxX = (containerSize.width * (newScale - 1) / 2f).coerceAtLeast(0f) - val maxY = (containerSize.height * (newScale - 1) / 2f).coerceAtLeast(0f) - - val newOffsetX = (offsetX + panChange.x).coerceIn(-maxX, maxX) - val newOffsetY = (offsetY + panChange.y).coerceIn(-maxY, maxY) - - scale = newScale - offsetX = newOffsetX - offsetY = newOffsetY - - // Consume if zoomed to prevent pager swipe - if (scale > 1.05f) { - event.changes.forEach { it.consume() } - } + // Decide: vertical dismiss or zoom/pan? + if (scale <= 1.05f && zoomChange == 1f && + abs(panChange.y) > abs(panChange.x) * 1.5f) { + lockedToDismiss = true + isVerticalDragging = true + } + } + } + + if (pastTouchSlop) { + if (lockedToDismiss) { + // Calculate velocity for smooth dismiss + val currentTime = System.currentTimeMillis() + val currentY = event.changes.firstOrNull()?.position?.y ?: lastDragY + val timeDelta = (currentTime - lastDragTime).coerceAtLeast(1L) + val positionDelta = currentY - lastDragY + + // Velocity in px/second + currentVelocity = (positionDelta / timeDelta) * 1000f + + lastDragTime = currentTime + lastDragY = currentY + + // Vertical drag for dismiss with velocity + onVerticalDrag(panChange.y, currentVelocity) + event.changes.forEach { it.consume() } + } else { + // Zoom and pan + val newScale = (scale * zoomChange).coerceIn(minScale, maxScale) + + // Calculate max offsets based on zoom + val maxX = (containerSize.width * (newScale - 1) / 2f).coerceAtLeast(0f) + val maxY = (containerSize.height * (newScale - 1) / 2f).coerceAtLeast(0f) + + val newOffsetX = (offsetX + panChange.x).coerceIn(-maxX, maxX) + val newOffsetY = (offsetY + panChange.y).coerceIn(-maxY, maxY) + + scale = newScale + offsetX = newOffsetX + offsetY = newOffsetY + + // Consume if zoomed to prevent pager swipe + if (scale > 1.05f) { + event.changes.forEach { it.consume() } } } } - } while (event.changes.any { it.pressed }) - - // Pointer up - end drag - if (isVerticalDragging) { - isVerticalDragging = false - onDragEnd() - } - - // Snap back if scale is close to 1 - if (scale < 1.05f) { - scale = 1f - offsetX = 0f - offsetY = 0f } + } while (event.changes.any { it.pressed }) + + // Pointer up - end drag + if (isVerticalDragging) { + isVerticalDragging = false + onDragEnd() + } + + // Snap back if scale is close to 1 + if (scale < 1.05f) { + scale = 1f + offsetX = 0f + offsetY = 0f } } },