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 hasReply = replyMessages.isNotEmpty()
|
||||||
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
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 - это новое сообщение внизу,
|
// reverseLayout=true означает что index 0 - это новое сообщение внизу,
|
||||||
// а большие индексы - старые сообщения вверху
|
// а большие индексы - старые сообщения вверху
|
||||||
// Используем snapshotFlow с debounce для плавной пагинации без прерывания скролла
|
// Используем snapshotFlow с debounce для плавной пагинации без прерывания скролла
|
||||||
@@ -445,7 +429,10 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Отслеживаем ID самого нового сообщения для умного скролла
|
// 🔥 Отслеживаем 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) }
|
var lastNewestMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
// 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
|
||||||
|
}
|
||||||
@@ -184,6 +184,9 @@ fun ProfileScreen(
|
|||||||
// Состояние меню аватара для установки фото профиля
|
// Состояние меню аватара для установки фото профиля
|
||||||
var showAvatarMenu by remember { mutableStateOf(false) }
|
var showAvatarMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🖼️ Состояние для нашего кастомного photo picker
|
||||||
|
var showPhotoPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// URI выбранного изображения (до crop)
|
// URI выбранного изображения (до crop)
|
||||||
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
|
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 backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme)
|
val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme)
|
||||||
@@ -654,7 +646,7 @@ fun ProfileScreen(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showAvatarMenu = showAvatarMenu,
|
showAvatarMenu = showAvatarMenu,
|
||||||
onAvatarMenuChange = { showAvatarMenu = it },
|
onAvatarMenuChange = { showAvatarMenu = it },
|
||||||
onSetPhotoClick = { imagePickerLauncher.launch("image/*") },
|
onSetPhotoClick = { showPhotoPicker = true },
|
||||||
onDeletePhotoClick = {
|
onDeletePhotoClick = {
|
||||||
// Удаляем аватар
|
// Удаляем аватар
|
||||||
scope.launch {
|
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