From 8c30fc35492fa1d9f3fa8006053586ae46d20fc7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 29 Jan 2026 21:54:17 +0500 Subject: [PATCH] feat: Add camera button to MediaGrid for quick access --- app/build.gradle.kts | 6 + .../rosetta/messenger/ui/auth/UnlockScreen.kt | 46 +++--- .../components/MediaPickerBottomSheet.kt | 148 +++++++++++++++++- .../messenger/ui/components/AvatarImage.kt | 16 +- 4 files changed, 181 insertions(+), 35 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61bd196..ceedac7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,6 +120,12 @@ dependencies { // Biometric authentication implementation("androidx.biometric:biometric:1.1.0") + // CameraX for camera preview + implementation("androidx.camera:camera-core:1.3.1") + implementation("androidx.camera:camera-camera2:1.3.1") + implementation("androidx.camera:camera-lifecycle:1.3.1") + implementation("androidx.camera:camera-view:1.3.1") + // Baseline Profiles for startup performance implementation("androidx.profileinstaller:profileinstaller:1.3.1") diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index a35aa86..d3c1cac 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -356,32 +356,26 @@ fun UnlockScreen( visible = visible, enter = fadeIn(tween(400)) + scaleIn(tween(400, easing = FastOutSlowInEasing)) ) { - Box( - modifier = Modifier - .size(120.dp) - .clip(RoundedCornerShape(28.dp)) - .background( - if (selectedAccount != null) { - val colors = getAvatarColor(selectedAccount!!.publicKey, isDarkTheme) - colors.backgroundColor - } else { - cardBackground - } - ), - contentAlignment = Alignment.Center - ) { - if (selectedAccount != null) { - val database = RosettaDatabase.getDatabase(context) - val avatarRepository = remember(selectedAccount!!.publicKey) { - AvatarRepository(context, database.avatarDao(), selectedAccount!!.publicKey) - } - AvatarImage( - publicKey = selectedAccount!!.publicKey, - avatarRepository = avatarRepository, - size = 120.dp, - isDarkTheme = isDarkTheme - ) - } else { + if (selectedAccount != null) { + val database = RosettaDatabase.getDatabase(context) + val avatarRepository = remember(selectedAccount!!.publicKey) { + AvatarRepository(context, database.avatarDao(), selectedAccount!!.publicKey) + } + AvatarImage( + publicKey = selectedAccount!!.publicKey, + avatarRepository = avatarRepository, + size = 120.dp, + isDarkTheme = isDarkTheme, + shape = RoundedCornerShape(28.dp) + ) + } else { + Box( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(28.dp)) + .background(cardBackground), + contentAlignment = Alignment.Center + ) { Text( text = "?", fontSize = 48.sp, 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 2e0dd7d..df7ffd1 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 @@ -30,10 +30,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import coil.compose.AsyncImage import coil.request.ImageRequest @@ -112,10 +118,14 @@ fun MediaPickerBottomSheet( val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.CAMERA ) } else { - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.CAMERA + ) } hasPermission = permissions.all { @@ -211,10 +221,14 @@ fun MediaPickerBottomSheet( val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.CAMERA ) } else { - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.CAMERA + ) } permissionLauncher.launch(permissions) } @@ -262,6 +276,10 @@ fun MediaPickerBottomSheet( MediaGrid( mediaItems = mediaItems, selectedItems = selectedItems, + onCameraClick = { + onDismiss() + onOpenCamera() + }, onItemClick = { item -> // Telegram-style: // - Первый клик по невыбранной фото → выбрать @@ -465,6 +483,7 @@ private fun QuickActionButton( private fun MediaGrid( mediaItems: List, selectedItems: Set, + onCameraClick: () -> Unit, onItemClick: (MediaItem) -> Unit, onItemLongClick: (MediaItem) -> Unit, isDarkTheme: Boolean, @@ -480,6 +499,15 @@ private fun MediaGrid( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { + // 📷 Camera button as first item + item(key = "camera_button") { + CameraGridItem( + onClick = onCameraClick, + isDarkTheme = isDarkTheme + ) + } + + // Media items items( items = mediaItems, key = { it.id } @@ -498,6 +526,118 @@ private fun MediaGrid( } } +/** + * 📷 Camera button in grid - first item with live preview + */ +@Composable +private fun CameraGridItem( + onClick: () -> Unit, + isDarkTheme: Boolean +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + + // Check if camera permission is granted - use mutableState for reactivity + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) + } + + // Re-check permission when component becomes visible + LaunchedEffect(Unit) { + hasCameraPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + if (hasCameraPermission) { + // Show live camera preview + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + try { + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + // Use back camera + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview + ) + } catch (e: Exception) { + // Camera init failed + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + }, + modifier = Modifier.fillMaxSize() + ) + + // Camera icon overlay + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.Camera, + contentDescription = "Camera", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } else { + // No permission - show placeholder + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = TablerIcons.Camera, + contentDescription = "Camera", + tint = PrimaryBlue, + modifier = Modifier.size(40.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Camera", + color = PrimaryBlue, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} + @Composable private fun MediaGridItem( item: MediaItem, diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 7b3c879..bc85eda 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Shape import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -44,6 +46,7 @@ import kotlinx.coroutines.withContext * @param onClick Обработчик клика (опционально) * @param showOnlineIndicator Показывать индикатор онлайн * @param isOnline Пользователь онлайн + * @param shape Форма аватара (по умолчанию круг) */ @Composable fun AvatarImage( @@ -53,7 +56,8 @@ fun AvatarImage( isDarkTheme: Boolean, onClick: (() -> Unit)? = null, showOnlineIndicator: Boolean = false, - isOnline: Boolean = false + isOnline: Boolean = false, + shape: Shape = CircleShape ) { // Получаем аватары из репозитория val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() @@ -85,7 +89,7 @@ fun AvatarImage( Box( modifier = Modifier .size(size) - .clip(CircleShape) + .clip(shape) .then( if (onClick != null) { Modifier.clickable(onClick = onClick) @@ -113,7 +117,8 @@ fun AvatarImage( AvatarPlaceholder( publicKey = publicKey, size = size, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme, + shape = shape ) } @@ -137,7 +142,8 @@ fun AvatarPlaceholder( publicKey: String, size: Dp = 40.dp, isDarkTheme: Boolean, - fontSize: TextUnit? = null + fontSize: TextUnit? = null, + shape: Shape = CircleShape ) { val avatarColors = getAvatarColor(publicKey, isDarkTheme) val avatarText = getAvatarText(publicKey) @@ -145,7 +151,7 @@ fun AvatarPlaceholder( Box( modifier = Modifier .size(size) - .clip(CircleShape) + .clip(shape) .background(avatarColors.backgroundColor), contentAlignment = Alignment.Center ) {