From d5d3d5e56d33e92a2096b5d9116463aa6a8a00d7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 4 Feb 2026 02:16:26 +0500 Subject: [PATCH] feat: add in-app camera component for photo capture --- .../messenger/ui/chats/ChatDetailScreen.kt | 37 +- .../ui/chats/components/ImageEditorScreen.kt | 20 +- .../ui/chats/components/InAppCameraScreen.kt | 375 ++++++++++++++++++ 3 files changed, 397 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index b65fc80..2e047ed 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -75,6 +75,7 @@ import com.rosetta.messenger.utils.MediaUtils import com.rosetta.messenger.ui.chats.components.ImageEditorScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.components.ImageWithCaption +import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import androidx.compose.runtime.collectAsState import androidx.activity.compose.rememberLauncherForActivityResult @@ -220,7 +221,10 @@ fun ChatDetailScreen( // πŸ“· БостояниС для flow ΠΊΠ°ΠΌΠ΅Ρ€Ρ‹: Ρ„ΠΎΡ‚ΠΎ β†’ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ с caption β†’ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° var pendingCameraPhotoUri by remember { mutableStateOf(null) } // Π€ΠΎΡ‚ΠΎ для рСдактирования - // οΏ½ БостояниС для multi-image editor (галСрСя) + // πŸ“· ΠŸΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ Π²ΡΡ‚Ρ€ΠΎΠ΅Π½Π½ΡƒΡŽ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ (Π±Π΅Π· систСмного ΠΏΡ€Π΅Π²ΡŒΡŽ) + var showInAppCamera by remember { mutableStateOf(false) } + + // πŸ–Ό БостояниС для multi-image editor (галСрСя) var pendingGalleryImages by remember { mutableStateOf>(emptyList()) } // οΏ½πŸ“· Camera launcher @@ -1877,25 +1881,10 @@ fun ChatDetailScreen( } }, onOpenCamera = { - // πŸ“· ΠžΡ‡ΠΈΡ‰Π°Π΅ΠΌ фокус ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅ΠΌ ΠΊΠ°ΠΌΠ΅Ρ€Ρ‹ + // πŸ“· ΠžΡ‚ΠΊΡ€Ρ‹Π²Π°Π΅ΠΌ Π²ΡΡ‚Ρ€ΠΎΠ΅Π½Π½ΡƒΡŽ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ (Π±Π΅Π· систСмного ΠΏΡ€Π΅Π²ΡŒΡŽ!) keyboardController?.hide() focusManager.clearFocus() - - // Π‘ΠΎΠ·Π΄Π°Ρ‘ΠΌ Π²Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹ΠΉ Ρ„Π°ΠΉΠ» для Ρ„ΠΎΡ‚ΠΎ - try { - val photoFile = File.createTempFile( - "photo_${System.currentTimeMillis()}", - ".jpg", - context.cacheDir - ) - cameraImageUri = FileProvider.getUriForFile( - context, - "${context.packageName}.provider", - photoFile - ) - cameraLauncher.launch(cameraImageUri!!) - } catch (e: Exception) { - } + showInAppCamera = true }, onOpenFilePicker = { // πŸ“„ ΠžΡ‚ΠΊΡ€Ρ‹Π²Π°Π΅ΠΌ Ρ„Π°ΠΉΠ»ΠΎΠ²Ρ‹ΠΉ ΠΏΠΈΠΊΠ΅Ρ€ @@ -2143,6 +2132,18 @@ fun ChatDetailScreen( ) } + // πŸ“· In-App Camera (Π±Π΅Π· систСмного ΠΏΡ€Π΅Π²ΡŒΡŽ!) + if (showInAppCamera) { + InAppCameraScreen( + onDismiss = { showInAppCamera = false }, + onPhotoTaken = { photoUri -> + // Π‘Ρ€Π°Π·Ρƒ ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°Π΅ΠΌ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€! + showInAppCamera = false + pendingCameraPhotoUri = photoUri + } + ) + } + // πŸ“· Image Editor для Ρ„ΠΎΡ‚ΠΎ с ΠΊΠ°ΠΌΠ΅Ρ€Ρ‹ (с caption ΠΊΠ°ΠΊ Π² Telegram) pendingCameraPhotoUri?.let { uri -> ImageEditorScreen( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 6e200f2..56593b7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -426,20 +426,13 @@ fun ImageEditorScreen( ) // ═══════════════════════════════════════════════════════════ - // πŸŽ›οΈ TOP BAR - Transparent overlay (Telegram style) + // πŸŽ›οΈ TOP BAR - Solid black (Telegram style) // ═══════════════════════════════════════════════════════════ Box( modifier = Modifier .fillMaxWidth() .align(Alignment.TopCenter) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.5f), - Color.Transparent - ) - ) - ) + .background(Color.Black) .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 8.dp) ) { @@ -1716,14 +1709,7 @@ fun MultiImageEditorScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.TopCenter) - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.5f), - Color.Transparent - ) - ) - ) + .background(Color.Black) .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 8.dp) ) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt new file mode 100644 index 0000000..71cccf5 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -0,0 +1,375 @@ +package com.rosetta.messenger.ui.chats.components + +import android.content.Context +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import androidx.activity.compose.BackHandler +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FlipCameraAndroid +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.FlashAuto +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.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import kotlinx.coroutines.launch +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import android.app.Activity +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.ui.graphics.graphicsLayer + +/** + * πŸ“· In-App Camera Screen - ΠΊΠ°ΠΊ Π² Telegram + * ΠšΠ°ΡΡ‚ΠΎΠΌΠ½Π°Ρ ΠΊΠ°ΠΌΠ΅Ρ€Π° Π±Π΅Π· систСмного ΠΏΡ€Π΅Π²ΡŒΡŽ, сразу ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΈΡ‚ Π² ImageEditorScreen + */ +@Composable +fun InAppCameraScreen( + onDismiss: () -> Unit, + onPhotoTaken: (Uri) -> Unit // ВызываСтся с URI сдСланного Ρ„ΠΎΡ‚ΠΎ +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val view = LocalView.current + + // Camera state + var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } + var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) } + var isCapturing by remember { mutableStateOf(false) } + + // Camera references + var imageCapture by remember { mutableStateOf(null) } + var cameraProvider by remember { mutableStateOf(null) } + + // ═══════════════════════════════════════════════════════════════ + // 🎬 FADE ANIMATION (ΠΊΠ°ΠΊ Π² ImageEditorScreen) + // ═══════════════════════════════════════════════════════════════ + var isClosing by remember { mutableStateOf(false) } + val animationProgress = remember { Animatable(0f) } + + // Enter animation + LaunchedEffect(Unit) { + animationProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) + ) + } + + // Animated dismiss function + fun animatedDismiss() { + if (isClosing) return + isClosing = true + scope.launch { + animationProgress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 150, easing = LinearEasing) + ) + onDismiss() + } + } + + // ═══════════════════════════════════════════════════════════════ + // 🎨 Status bar (Ρ‡Π΅Ρ€Π½Ρ‹ΠΉ ΠΊΠ°ΠΊ Π² ImageEditorScreen) + // ═══════════════════════════════════════════════════════════════ + val activity = context as? Activity + val window = activity?.window + + val originalStatusBarColor = remember { window?.statusBarColor ?: android.graphics.Color.WHITE } + val originalNavigationBarColor = remember { window?.navigationBarColor ?: android.graphics.Color.WHITE } + val insetsController = remember(window, view) { window?.let { WindowCompat.getInsetsController(it, view) } } + val originalLightStatusBars = remember { insetsController?.isAppearanceLightStatusBars ?: true } + val originalLightNavigationBars = remember { insetsController?.isAppearanceLightNavigationBars ?: true } + + LaunchedEffect(animationProgress.value) { + if (window == null || insetsController == null) return@LaunchedEffect + + val progress = animationProgress.value + val currentStatusColor = androidx.core.graphics.ColorUtils.blendARGB( + originalStatusBarColor, android.graphics.Color.BLACK, progress + ) + val currentNavColor = androidx.core.graphics.ColorUtils.blendARGB( + originalNavigationBarColor, android.graphics.Color.BLACK, progress + ) + + window.statusBarColor = currentStatusColor + window.navigationBarColor = currentNavColor + insetsController.isAppearanceLightStatusBars = progress < 0.5f && originalLightStatusBars + insetsController.isAppearanceLightNavigationBars = progress < 0.5f && originalLightNavigationBars + } + + DisposableEffect(window) { + onDispose { + if (window == null || insetsController == null) return@onDispose + window.statusBarColor = originalStatusBarColor + window.navigationBarColor = originalNavigationBarColor + insetsController.isAppearanceLightStatusBars = originalLightStatusBars + insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars + } + } + + BackHandler { animatedDismiss() } + + // ═══════════════════════════════════════════════════════════════ + // πŸ“· CAMERA SETUP + // ═══════════════════════════════════════════════════════════════ + + // Shutter button animation + val shutterScale by animateFloatAsState( + targetValue = if (isCapturing) 0.85f else 1f, + animationSpec = tween(100) + ) + + // Take photo function + fun takePhoto() { + val capture = imageCapture ?: return + if (isCapturing) return + + isCapturing = true + + // Create output file + val photoFile = File( + context.cacheDir, + "photo_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.jpg" + ) + + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() + + capture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + val savedUri = Uri.fromFile(photoFile) + // Π‘Ρ€Π°Π·Ρƒ ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°Π΅ΠΌ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ Π±Π΅Π· ΠΏΡ€ΠΎΠΌΠ΅ΠΆΡƒΡ‚ΠΎΡ‡Π½ΠΎΠ³ΠΎ ΠΏΡ€Π΅Π²ΡŒΡŽ! + onPhotoTaken(savedUri) + } + + override fun onError(exc: ImageCaptureException) { + Log.e("InAppCamera", "Photo capture failed: ${exc.message}", exc) + isCapturing = false + } + } + ) + } + + // ═══════════════════════════════════════════════════════════════ + // 🎨 UI + // ═══════════════════════════════════════════════════════════════ + + // PreviewView reference + var previewView by remember { mutableStateOf(null) } + + // Bind camera when previewView, lensFacing or flashMode changes + LaunchedEffect(previewView, lensFacing, flashMode) { + val pv = previewView ?: return@LaunchedEffect + + val provider = context.getCameraProvider() + cameraProvider = provider + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(pv.surfaceProvider) + } + + val capture = ImageCapture.Builder() + .setFlashMode(flashMode) + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + imageCapture = capture + + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + + try { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + capture + ) + } catch (e: Exception) { + Log.e("InAppCamera", "Use case binding failed", e) + } + } + + // Unbind on dispose + DisposableEffect(Unit) { + onDispose { + cameraProvider?.unbindAll() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = animationProgress.value } + .background(Color.Black) + ) { + // Camera Preview + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + previewView = this + } + }, + modifier = Modifier.fillMaxSize() + ) + + // Top controls (Close + Flash) + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Close button + IconButton( + onClick = { animatedDismiss() }, + modifier = Modifier + .size(44.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White + ) + } + + // Flash button + IconButton( + onClick = { + flashMode = when (flashMode) { + ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO + ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON + else -> ImageCapture.FLASH_MODE_OFF + } + }, + modifier = Modifier + .size(44.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + when (flashMode) { + ImageCapture.FLASH_MODE_OFF -> Icons.Default.FlashOff + ImageCapture.FLASH_MODE_AUTO -> Icons.Default.FlashAuto + else -> Icons.Default.FlashOn + }, + contentDescription = "Flash", + tint = Color.White + ) + } + } + + // Bottom controls (Shutter + Flip) + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 32.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + // Placeholder for symmetry + Spacer(modifier = Modifier.size(60.dp)) + + // Shutter button + Box( + modifier = Modifier + .size(80.dp) + .scale(shutterScale) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.2f)) + .border(4.dp, Color.White, CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { takePhoto() }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(Color.White) + ) + } + + // Flip camera button + IconButton( + onClick = { + lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { + CameraSelector.LENS_FACING_FRONT + } else { + CameraSelector.LENS_FACING_BACK + } + }, + modifier = Modifier + .size(60.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + Icons.Default.FlipCameraAndroid, + contentDescription = "Flip camera", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + } +} + +/** + * ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ CameraProvider с suspend + */ +private suspend fun Context.getCameraProvider(): ProcessCameraProvider { + return suspendCoroutine { continuation -> + val cameraProviderFuture = ProcessCameraProvider.getInstance(this) + cameraProviderFuture.addListener({ + continuation.resume(cameraProviderFuture.get()) + }, ContextCompat.getMainExecutor(this)) + } +}