feat: Add camera button to MediaGrid for quick access

This commit is contained in:
k1ngsterr1
2026-01-29 21:54:17 +05:00
parent 5d1ba8144f
commit 8c30fc3549
4 changed files with 181 additions and 35 deletions

View File

@@ -120,6 +120,12 @@ dependencies {
// Biometric authentication // Biometric authentication
implementation("androidx.biometric:biometric:1.1.0") 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 // Baseline Profiles for startup performance
implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.profileinstaller:profileinstaller:1.3.1")

View File

@@ -355,20 +355,6 @@ fun UnlockScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400)) + scaleIn(tween(400, easing = FastOutSlowInEasing)) 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) { if (selectedAccount != null) {
val database = RosettaDatabase.getDatabase(context) val database = RosettaDatabase.getDatabase(context)
@@ -379,9 +365,17 @@ fun UnlockScreen(
publicKey = selectedAccount!!.publicKey, publicKey = selectedAccount!!.publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 120.dp, size = 120.dp,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
shape = RoundedCornerShape(28.dp)
) )
} else { } else {
Box(
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(28.dp))
.background(cardBackground),
contentAlignment = Alignment.Center
) {
Text( Text(
text = "?", text = "?",
fontSize = 48.sp, fontSize = 48.sp,

View File

@@ -30,10 +30,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 androidx.core.content.ContextCompat
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
@@ -112,10 +118,14 @@ fun MediaPickerBottomSheet(
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf( arrayOf(
Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
) )
} else { } else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
} }
hasPermission = permissions.all { hasPermission = permissions.all {
@@ -211,10 +221,14 @@ fun MediaPickerBottomSheet(
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf( arrayOf(
Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
) )
} else { } else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
} }
permissionLauncher.launch(permissions) permissionLauncher.launch(permissions)
} }
@@ -262,6 +276,10 @@ fun MediaPickerBottomSheet(
MediaGrid( MediaGrid(
mediaItems = mediaItems, mediaItems = mediaItems,
selectedItems = selectedItems, selectedItems = selectedItems,
onCameraClick = {
onDismiss()
onOpenCamera()
},
onItemClick = { item -> onItemClick = { item ->
// Telegram-style: // Telegram-style:
// - Первый клик по невыбранной фото → выбрать // - Первый клик по невыбранной фото → выбрать
@@ -465,6 +483,7 @@ private fun QuickActionButton(
private fun MediaGrid( private fun MediaGrid(
mediaItems: List<MediaItem>, mediaItems: List<MediaItem>,
selectedItems: Set<Long>, selectedItems: Set<Long>,
onCameraClick: () -> Unit,
onItemClick: (MediaItem) -> Unit, onItemClick: (MediaItem) -> Unit,
onItemLongClick: (MediaItem) -> Unit, onItemLongClick: (MediaItem) -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
@@ -480,6 +499,15 @@ private fun MediaGrid(
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = 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(
items = mediaItems, items = mediaItems,
key = { it.id } 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 @Composable
private fun MediaGridItem( private fun MediaGridItem(
item: MediaItem, item: MediaItem,

View File

@@ -7,7 +7,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.graphics.Shape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -44,6 +46,7 @@ import kotlinx.coroutines.withContext
* @param onClick Обработчик клика (опционально) * @param onClick Обработчик клика (опционально)
* @param showOnlineIndicator Показывать индикатор онлайн * @param showOnlineIndicator Показывать индикатор онлайн
* @param isOnline Пользователь онлайн * @param isOnline Пользователь онлайн
* @param shape Форма аватара (по умолчанию круг)
*/ */
@Composable @Composable
fun AvatarImage( fun AvatarImage(
@@ -53,7 +56,8 @@ fun AvatarImage(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
showOnlineIndicator: Boolean = false, showOnlineIndicator: Boolean = false,
isOnline: Boolean = false isOnline: Boolean = false,
shape: Shape = CircleShape
) { ) {
// Получаем аватары из репозитория // Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
@@ -85,7 +89,7 @@ fun AvatarImage(
Box( Box(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(shape)
.then( .then(
if (onClick != null) { if (onClick != null) {
Modifier.clickable(onClick = onClick) Modifier.clickable(onClick = onClick)
@@ -113,7 +117,8 @@ fun AvatarImage(
AvatarPlaceholder( AvatarPlaceholder(
publicKey = publicKey, publicKey = publicKey,
size = size, size = size,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
shape = shape
) )
} }
@@ -137,7 +142,8 @@ fun AvatarPlaceholder(
publicKey: String, publicKey: String,
size: Dp = 40.dp, size: Dp = 40.dp,
isDarkTheme: Boolean, isDarkTheme: Boolean,
fontSize: TextUnit? = null fontSize: TextUnit? = null,
shape: Shape = CircleShape
) { ) {
val avatarColors = getAvatarColor(publicKey, isDarkTheme) val avatarColors = getAvatarColor(publicKey, isDarkTheme)
val avatarText = getAvatarText(publicKey) val avatarText = getAvatarText(publicKey)
@@ -145,7 +151,7 @@ fun AvatarPlaceholder(
Box( Box(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(shape)
.background(avatarColors.backgroundColor), .background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {