feat: implement custom profile photo picker with smooth animations and gallery integration

This commit is contained in:
k1ngsterr1
2026-02-02 16:25:01 +05:00
parent c41c27e6d9
commit 0c5abd976e
3 changed files with 618 additions and 31 deletions

View File

@@ -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<String?>(null) }
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)

View File

@@ -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<List<PhotoItem>>(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<PhotoItem>,
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<PhotoItem> = withContext(Dispatchers.IO) {
val items = mutableListOf<PhotoItem>()
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
}

View File

@@ -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<Uri?>(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
)
}
// ═════════════════════════════════════════════════════════════