From 0c5abd976e82b5fd61aba4b74baaf077e6793197 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 2 Feb 2026 16:25:01 +0500 Subject: [PATCH] feat: implement custom profile photo picker with smooth animations and gallery integration --- .../messenger/ui/chats/ChatDetailScreen.kt | 25 +- .../ui/settings/ProfilePhotoPicker.kt | 595 ++++++++++++++++++ .../messenger/ui/settings/ProfileScreen.kt | 29 +- 3 files changed, 618 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/settings/ProfilePhotoPicker.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 31e86c3..992aacf 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 @@ -270,25 +270,9 @@ fun ChatDetailScreen( val hasReply = replyMessages.isNotEmpty() val isForwardMode by viewModel.isForwardMode.collectAsState() - // 🔥 Количество сообщений для отслеживания пагинации - val messagesCount = messages.size - var previousMessagesCount by remember { mutableStateOf(messagesCount) } - - // 🔥 ПАГИНАЦИЯ: Сохраняем позицию скролла при подгрузке старых сообщений - LaunchedEffect(messagesCount) { - if (messagesCount > previousMessagesCount && previousMessagesCount > 0) { - // Загрузились новые (старые) сообщения - корректируем позицию - val addedCount = messagesCount - previousMessagesCount - val currentIndex = listState.firstVisibleItemIndex - val currentOffset = listState.firstVisibleItemScrollOffset - - // Прокручиваем на количество добавленных элементов, чтобы остаться на месте - listState.scrollToItem(currentIndex + addedCount, currentOffset) - } - previousMessagesCount = messagesCount - } - // 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх + // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true + // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) // reverseLayout=true означает что index 0 - это новое сообщение внизу, // а большие индексы - старые сообщения вверху // Используем snapshotFlow с debounce для плавной пагинации без прерывания скролла @@ -445,7 +429,10 @@ fun ChatDetailScreen( } // 🔥 Отслеживаем ID самого нового сообщения для умного скролла - val newestMessageId = messages.firstOrNull()?.id + // ВАЖНО: Используем maxByOrNull по timestamp, НЕ firstOrNull()! + // При пагинации старые сообщения добавляются в начало списка, + // поэтому firstOrNull() возвращает старое сообщение, а не новое. + val newestMessageId = messages.maxByOrNull { it.timestamp.time }?.id var lastNewestMessageId by remember { mutableStateOf(null) } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfilePhotoPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfilePhotoPicker.kt new file mode 100644 index 0000000..98d249e --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfilePhotoPicker.kt @@ -0,0 +1,595 @@ +package com.rosetta.messenger.ui.settings + +import android.Manifest +import android.content.ContentUris +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.core.content.ContextCompat +import coil.compose.AsyncImage +import coil.request.ImageRequest +import compose.icons.TablerIcons +import compose.icons.tablericons.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +private const val TAG = "ProfilePhotoPicker" + +/** + * 🖼️ Telegram-style Profile Photo Picker + * + * Красивый, быстрый fullscreen picker для выбора фото профиля: + * - Плавная slide-up анимация появления + * - Drag-to-dismiss как в Telegram + * - Затемнение фона (scrim) + * - Кастомная галерея с lazy loading + * - Single selection mode для аватара + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilePhotoPicker( + isVisible: Boolean, + onDismiss: () -> Unit, + onPhotoSelected: (Uri) -> Unit, + isDarkTheme: Boolean +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + val configuration = LocalConfiguration.current + + // Screen dimensions + val screenHeight = configuration.screenHeightDp.dp + val screenHeightPx = with(density) { screenHeight.toPx() } + + // Media items from gallery (only images for avatar) + var mediaItems by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var hasPermission by remember { mutableStateOf(false) } + + // 🎬 Animation state + var shouldShow by remember { mutableStateOf(false) } + var isClosing by remember { mutableStateOf(false) } + + // Animatable для высоты sheet с drag support + val sheetHeightPx = remember { Animatable(0f) } + val targetHeightPx = screenHeightPx * 0.92f // 92% экрана + + // Permission launcher + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + hasPermission = permissions.values.all { it } + if (hasPermission) { + scope.launch { + mediaItems = loadPhotos(context) + isLoading = false + } + } + } + + // Check permission on show + LaunchedEffect(isVisible) { + if (isVisible) { + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + hasPermission = permissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + + if (hasPermission) { + isLoading = true + mediaItems = loadPhotos(context) + isLoading = false + } else { + permissionLauncher.launch(permissions) + } + } + } + + // 🎬 Slide animation + val animatedOffset by animateFloatAsState( + targetValue = if (shouldShow && !isClosing) 0f else 1f, + animationSpec = if (isClosing) { + tween(220, easing = FastOutSlowInEasing) + } else { + tween(320, easing = FastOutSlowInEasing) + }, + finishedListener = { + if (isClosing) { + isClosing = false + shouldShow = false + scope.launch { + sheetHeightPx.snapTo(0f) + } + onDismiss() + } + }, + label = "sheet_slide" + ) + + // 🌑 Scrim (затемнение фона) + val scrimAlpha by animateFloatAsState( + targetValue = if (shouldShow && !isClosing) 0.5f else 0f, + animationSpec = tween( + durationMillis = if (isClosing) 200 else 300, + easing = FastOutSlowInEasing + ), + label = "scrim_fade" + ) + + // Запускаем анимацию появления + LaunchedEffect(isVisible) { + if (isVisible) { + shouldShow = true + isClosing = false + sheetHeightPx.snapTo(targetHeightPx) + } + } + + // Функция анимированного закрытия + val animatedClose: () -> Unit = { + if (!isClosing) { + isClosing = true + } + } + + // BackHandler + BackHandler(enabled = shouldShow && !isClosing) { + animatedClose() + } + + // 🎨 Затемнение статус бара когда галерея открыта + 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 { + onDispose { } + } + } + + // Colors + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + val textColor = if (isDarkTheme) Color.White else Color.Black + val accentColor = Color(0xFF007AFF) + val handleColor = if (isDarkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.2f) + + // Popup для fullscreen overlay + if (shouldShow) { + Popup( + onDismissRequest = animatedClose, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = scrimAlpha)) + .pointerInput(Unit) { + detectTapGestures { animatedClose() } + } + ) { + // 📱 Sheet content - выезжает снизу + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(0.92f) + .offset { + IntOffset(0, (screenHeightPx * animatedOffset).roundToInt()) + } + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .pointerInput(Unit) { + // Drag to dismiss + var totalDrag = 0f + detectVerticalDragGestures( + onDragStart = { totalDrag = 0f }, + onDragEnd = { + if (totalDrag > 150f) { + animatedClose() + } + }, + onDragCancel = { totalDrag = 0f }, + onVerticalDrag = { _, dragAmount -> + if (dragAmount > 0) { // Only downward + totalDrag += dragAmount + } + } + ) + }, + color = backgroundColor, + shadowElevation = 16.dp + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ═══════════════════════════════════════════════════════════ + // 🎯 DRAG HANDLE + HEADER + // ═══════════════════════════════════════════════════════════ + Column( + modifier = Modifier + .fillMaxWidth() + .background(surfaceColor) + ) { + // Drag handle + Box( + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(handleColor) + ) + + // Header + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp) + ) { + // Close button + IconButton( + onClick = animatedClose, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Icon( + imageVector = TablerIcons.X, + contentDescription = "Close", + tint = textColor, + modifier = Modifier.size(24.dp) + ) + } + + // Title + Text( + text = "Choose Photo", + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + modifier = Modifier.align(Alignment.Center) + ) + } + } + + // ═══════════════════════════════════════════════════════════ + // 📷 GALLERY GRID + // ═══════════════════════════════════════════════════════════ + when { + isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = accentColor, + strokeWidth = 2.dp, + modifier = Modifier.size(32.dp) + ) + } + } + + !hasPermission -> { + PermissionRequest( + onRequestPermission = { + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + permissionLauncher.launch(permissions) + }, + isDarkTheme = isDarkTheme + ) + } + + mediaItems.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = TablerIcons.Photo, + contentDescription = null, + tint = textColor.copy(alpha = 0.4f), + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No photos found", + color = textColor.copy(alpha = 0.6f), + fontSize = 16.sp + ) + } + } + } + + else -> { + PhotoGrid( + photos = mediaItems, + onPhotoClick = { photo -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onPhotoSelected(photo.uri) + // Don't call animatedClose - let parent handle dismiss after crop + }, + isDarkTheme = isDarkTheme + ) + } + } + } + } + } + } + } +} + +/** + * Photo item data class (simplified for avatars - only images) + */ +private data class PhotoItem( + val id: Long, + val uri: Uri, + val dateModified: Long = 0 +) + +/** + * Photo grid with 3 columns + */ +@Composable +private fun PhotoGrid( + photos: List, + onPhotoClick: (PhotoItem) -> Unit, + isDarkTheme: Boolean +) { + val spacing = 2.dp + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(spacing), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + modifier = Modifier.fillMaxSize() + ) { + items( + items = photos, + key = { it.id } + ) { photo -> + PhotoGridItem( + photo = photo, + onClick = { onPhotoClick(photo) } + ) + } + } +} + +/** + * Single photo item in grid + */ +@Composable +private fun PhotoGridItem( + photo: PhotoItem, + onClick: () -> Unit +) { + val context = LocalContext.current + var isPressed by remember { mutableStateOf(false) } + + // Scale animation on press + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "scale" + ) + + Box( + modifier = Modifier + .aspectRatio(1f) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(RoundedCornerShape(2.dp)) + .background(Color(0xFF2C2C2E)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { onClick() } + ) + } + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(photo.uri) + .crossfade(100) + .size(400) // Reasonable thumbnail size + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } +} + +/** + * Permission request screen + */ +@Composable +private fun PermissionRequest( + onRequestPermission: () -> Unit, + isDarkTheme: Boolean +) { + val textColor = if (isDarkTheme) Color.White else Color.Black + val accentColor = Color(0xFF007AFF) + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Icon( + imageVector = TablerIcons.Photo, + contentDescription = null, + tint = textColor.copy(alpha = 0.5f), + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Photo Access Required", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Allow access to your photos to set a profile picture", + fontSize = 15.sp, + color = textColor.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onRequestPermission, + colors = ButtonDefaults.buttonColors( + containerColor = accentColor + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text( + text = "Allow Access", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +/** + * Load photos from device gallery (only images, no videos) + */ +private suspend fun loadPhotos(context: Context): List = withContext(Dispatchers.IO) { + val items = mutableListOf() + + try { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DATE_MODIFIED + ) + + val sortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC" + + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + null, + null, + sortOrder + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val dateModified = cursor.getLong(dateColumn) + + val uri = ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + id + ) + + items.add(PhotoItem( + id = id, + uri = uri, + dateModified = dateModified + )) + } + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Error loading photos: ${e.message}", e) + } + + items +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 331f729..5d53f22 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -183,6 +183,9 @@ fun ProfileScreen( // Состояние меню аватара для установки фото профиля var showAvatarMenu by remember { mutableStateOf(false) } + + // 🖼️ Состояние для нашего кастомного photo picker + var showPhotoPicker by remember { mutableStateOf(false) } // URI выбранного изображения (до crop) var selectedImageUri by remember { mutableStateOf(null) } @@ -246,17 +249,6 @@ fun ProfileScreen( } } - // Image picker launcher - после выбора открываем crop - val imagePickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { - uri: Uri? -> - uri?.let { - // Запускаем uCrop для обрезки - val cropIntent = ImageCropHelper.createCropIntent(context, it, isDarkTheme) - cropLauncher.launch(cropIntent) - } - } - // Цвета в зависимости от темы val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) @@ -654,7 +646,7 @@ fun ProfileScreen( isDarkTheme = isDarkTheme, showAvatarMenu = showAvatarMenu, onAvatarMenuChange = { showAvatarMenu = it }, - onSetPhotoClick = { imagePickerLauncher.launch("image/*") }, + onSetPhotoClick = { showPhotoPicker = true }, onDeletePhotoClick = { // Удаляем аватар scope.launch { @@ -761,6 +753,19 @@ fun ProfileScreen( } ) } + + // 🖼️ Кастомный быстрый Photo Picker + ProfilePhotoPicker( + isVisible = showPhotoPicker, + onDismiss = { showPhotoPicker = false }, + onPhotoSelected = { uri -> + showPhotoPicker = false + // Запускаем uCrop для обрезки + val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme) + cropLauncher.launch(cropIntent) + }, + isDarkTheme = isDarkTheme + ) } // ═════════════════════════════════════════════════════════════