feat: implement custom profile photo picker with smooth animations and gallery integration
This commit is contained in:
@@ -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: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user