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 10ade13..1e2a65f 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 @@ -184,82 +184,197 @@ fun MediaPickerBottomSheet( // ═══════════════════════════════════════════════════════════════ val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp + val screenHeightPx = with(density) { screenHeight.toPx() } - // Высота галереи - большая, почти половина экрана - val sheetHeight = screenHeight * 0.55f + // 🔄 Высоты в пикселях для точного контроля + val collapsedHeightPx = screenHeightPx * 0.45f // Свёрнутое - 45% экрана + val expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана + val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз - // Отступ снизу чтобы быть НАД клавиатурой (примерно высота input bar) - val bottomOffset = 56.dp + // 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ + val sheetHeightPx = remember { Animatable(collapsedHeightPx) } - // 🎬 Анимация появления - var animationStarted by remember { mutableStateOf(false) } + // Текущее состояние (для логики) + var isExpanded by remember { mutableStateOf(false) } + + // 🎬 Анимация появления/закрытия + var isClosing by remember { mutableStateOf(false) } + var shouldShow by remember { mutableStateOf(false) } + + // Scope для анимаций + val animationScope = rememberCoroutineScope() + + // Анимация slide для появления/закрытия val animatedOffset by animateFloatAsState( - targetValue = if (animationStarted) 0f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), + targetValue = if (shouldShow && !isClosing) 0f else 1f, + animationSpec = if (isClosing) { + tween(200, easing = FastOutSlowInEasing) + } else { + tween(280, easing = FastOutSlowInEasing) + }, + finishedListener = { + if (isClosing) { + isClosing = false + shouldShow = false + isExpanded = false + // Сбрасываем высоту + animationScope.launch { + sheetHeightPx.snapTo(collapsedHeightPx) + } + onDismiss() + } + }, label = "sheet_slide" ) - val animatedAlpha by animateFloatAsState( - targetValue = if (animationStarted) 1f else 0f, - animationSpec = tween(200), - label = "scrim_alpha" - ) - // Drag offset для свайпа вниз - var dragOffsetY by remember { mutableFloatStateOf(0f) } + // 🌑 Плавное затемнение экрана (включая статус бар) + // Мягкий fade-in вместо резкого черного overlay + val scrimAlpha by animateFloatAsState( + targetValue = if (shouldShow && !isClosing) { + // Базовое затемнение + немного больше когда развёрнуто + val expandProgress = (sheetHeightPx.value - collapsedHeightPx) / (expandedHeightPx - collapsedHeightPx) + 0.25f + 0.15f * expandProgress.coerceIn(0f, 1f) + } else { + 0f + }, + animationSpec = tween( + durationMillis = if (isClosing) 200 else 300, + easing = FastOutSlowInEasing + ), + label = "scrim_fade" + ) // Показываем галерею val showSheet = isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null - // Запускаем анимацию когда showSheet становится true + // Запускаем анимацию когда showSheet меняется LaunchedEffect(showSheet) { if (showSheet) { - animationStarted = true + shouldShow = true + isClosing = false + sheetHeightPx.snapTo(collapsedHeightPx) + } + } + + // Функция для анимированного закрытия + val animatedClose: () -> Unit = { + if (!isClosing) { + isClosing = true + } + } + + // Функция snap к ближайшему состоянию с плавной анимацией + fun snapToNearestState() { + animationScope.launch { + val currentHeight = sheetHeightPx.value + val midPoint = (collapsedHeightPx + expandedHeightPx) / 2 + + when { + // Слишком низко - закрываем + currentHeight < minHeightPx + 50 -> { + animatedClose() + } + // Ближе к expanded + currentHeight > midPoint -> { + isExpanded = true + sheetHeightPx.animateTo( + expandedHeightPx, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + // Ближе к collapsed + else -> { + isExpanded = false + sheetHeightPx.animateTo( + collapsedHeightPx, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + } + } + } + + // 🎨 Затемнение статус бара когда галерея открыта + val view = LocalView.current + DisposableEffect(shouldShow, scrimAlpha) { + if (shouldShow && !view.isInEditMode) { + val window = (view.context as? android.app.Activity)?.window + val originalStatusBarColor = window?.statusBarColor ?: 0 + + // Затемняем статус бар + val scrimColor = android.graphics.Color.argb( + (scrimAlpha * 255).toInt().coerceIn(0, 255), + 0, 0, 0 + ) + window?.statusBarColor = scrimColor + + onDispose { + // Восстанавливаем оригинальный цвет + window?.statusBarColor = originalStatusBarColor + } } else { - animationStarted = false + onDispose { } } } // Используем Popup для показа поверх клавиатуры - if (showSheet) { + if (shouldShow) { // BackHandler для закрытия по back - BackHandler { onDismiss() } + BackHandler { + if (isExpanded) { + animationScope.launch { + isExpanded = false + sheetHeightPx.animateTo( + collapsedHeightPx, + animationSpec = tween(250, easing = FastOutSlowInEasing) + ) + } + } else { + animatedClose() + } + } Popup( - alignment = Alignment.BottomCenter, - onDismissRequest = onDismiss, + alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран + onDismissRequest = animatedClose, properties = PopupProperties( - focusable = false, // НЕ забираем фокус - клавиатура остаётся! + focusable = false, dismissOnBackPress = true, - dismissOnClickOutside = true + dismissOnClickOutside = false ) ) { - // Полноэкранный контейнер с затемнением + // Полноэкранный контейнер с мягким затемнением Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f * animatedAlpha)) + .background(Color.Black.copy(alpha = scrimAlpha)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null - ) { onDismiss() }, + ) { animatedClose() }, contentAlignment = Alignment.BottomCenter ) { - // Sheet content - с анимированным offset - val sheetSlideOffset = with(density) { (sheetHeight.toPx() * animatedOffset).toInt() } + // Sheet content + val currentHeightDp = with(density) { sheetHeightPx.value.toDp() } + val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() + Column( modifier = Modifier .fillMaxWidth() - .height(sheetHeight) - .padding(bottom = bottomOffset) // Отступ от низа чтобы быть над input bar - .offset { IntOffset(0, (sheetSlideOffset + dragOffsetY).roundToInt()) } + .height(currentHeightDp) + .offset { IntOffset(0, slideOffset) } .graphicsLayer { - // Небольшой scale эффект при появлении + // Scale эффект только при появлении/закрытии val scale = 0.95f + 0.05f * (1f - animatedOffset) scaleX = scale scaleY = scale + alpha = 0.9f + 0.1f * (1f - animatedOffset) } .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) .background(backgroundColor) @@ -270,14 +385,16 @@ fun MediaPickerBottomSheet( .pointerInput(Unit) { detectVerticalDragGestures( onDragEnd = { - if (dragOffsetY > 100) { - onDismiss() - } - dragOffsetY = 0f + // Snap к ближайшему состоянию + snapToNearestState() }, - onVerticalDrag = { _, dragAmount -> - if (dragAmount > 0) { // Only drag down - dragOffsetY += dragAmount + onVerticalDrag = { change, dragAmount -> + change.consume() + // 🔥 КЛЮЧЕВОЕ: Меняем высоту в реальном времени! + val newHeight = (sheetHeightPx.value - dragAmount) + .coerceIn(minHeightPx, expandedHeightPx) + animationScope.launch { + sheetHeightPx.snapTo(newHeight) } } ) @@ -302,137 +419,134 @@ fun MediaPickerBottomSheet( // Header with action buttons MediaPickerHeader( selectedCount = selectedItems.size, - onDismiss = onDismiss, + onDismiss = animatedClose, onSend = { val selected = mediaItems.filter { it.id in selectedItems } - onMediaSelected(selected) - onDismiss() - }, - isDarkTheme = isDarkTheme, - textColor = textColor - ) - - // Quick action buttons row (Camera, Gallery, File, Avatar, etc.) - QuickActionsRow( - isDarkTheme = isDarkTheme, - onCameraClick = { - onDismiss() - onOpenCamera() - }, - onFileClick = { - onDismiss() - onOpenFilePicker() - }, - onAvatarClick = { - onDismiss() - onAvatarClick() - } - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Content - if (!hasPermission) { - // Permission request UI - PermissionRequestView( + onMediaSelected(selected) + animatedClose() + }, isDarkTheme = isDarkTheme, - textColor = textColor, - secondaryTextColor = secondaryTextColor, - onRequestPermission = { - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.CAMERA - ) - } else { - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.CAMERA - ) - } - permissionLauncher.launch(permissions) - } + textColor = textColor ) - } else if (isLoading) { - // Loading indicator - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - color = PrimaryBlue, - modifier = Modifier.size(48.dp) - ) - } - } else if (mediaItems.isEmpty()) { - // Empty state - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - TablerIcons.Photo, - contentDescription = null, - tint = secondaryTextColor, - modifier = Modifier.size(64.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "No photos or videos", - color = secondaryTextColor, - fontSize = 16.sp - ) - } - } - } else { - // Media grid - MediaGrid( - mediaItems = mediaItems, - selectedItems = selectedItems, + + // Quick action buttons row (Camera, Gallery, File, Avatar, etc.) + QuickActionsRow( + isDarkTheme = isDarkTheme, onCameraClick = { - onDismiss() + animatedClose() onOpenCamera() }, - onItemClick = { item, position -> - // Telegram-style: клик на фото сразу открывает редактор с caption - if (!item.isVideo) { - thumbnailPosition = position - editingItem = item - } else { - // Для видео - добавляем/убираем из selection + onFileClick = { + animatedClose() + onOpenFilePicker() + }, + onAvatarClick = { + animatedClose() + onAvatarClick() + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Content + if (!hasPermission) { + // Permission request UI + PermissionRequestView( + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + onRequestPermission = { + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.CAMERA + ) + } else { + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.CAMERA + ) + } + permissionLauncher.launch(permissions) + } + ) + } else if (isLoading) { + // Loading indicator + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = PrimaryBlue, + modifier = Modifier.size(48.dp) + ) + } + } else if (mediaItems.isEmpty()) { + // Empty state + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + TablerIcons.Photo, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No photos or videos", + color = secondaryTextColor, + fontSize = 16.sp + ) + } + } + } else { + // Media grid + MediaGrid( + mediaItems = mediaItems, + selectedItems = selectedItems, + onCameraClick = { + animatedClose() + onOpenCamera() + }, + onItemClick = { item, position -> + // Telegram-style: клик на фото сразу открывает редактор с caption + if (!item.isVideo) { + thumbnailPosition = position + editingItem = item + } else { + // Для видео - добавляем/убираем из selection + if (item.id in selectedItems) { + selectedItems = selectedItems - item.id + } else if (selectedItems.size < maxSelection) { + selectedItems = selectedItems + item.id + } + } + }, + onItemLongClick = { item -> + // Long press - снять выделение если выбрана if (item.id in selectedItems) { selectedItems = selectedItems - item.id } else if (selectedItems.size < maxSelection) { selectedItems = selectedItems + item.id } - } - }, - onItemLongClick = { item -> - // Long press - снять выделение если выбрана - if (item.id in selectedItems) { - selectedItems = selectedItems - item.id - } else if (selectedItems.size < maxSelection) { - selectedItems = selectedItems + item.id - } - }, - isDarkTheme = isDarkTheme, - modifier = Modifier.weight(1f) - ) + }, + isDarkTheme = isDarkTheme, + modifier = Modifier.weight(1f) + ) + } } - - // Bottom safe area - Spacer(modifier = Modifier.navigationBarsPadding()) } } - } } // Image Editor overlay для фото из галереи