feat: add in-app camera component for photo capture

This commit is contained in:
k1ngsterr1
2026-02-04 02:16:26 +05:00
parent bc59c6879a
commit d5d3d5e56d
3 changed files with 397 additions and 35 deletions

View File

@@ -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<Uri?>(null) } // Фото для редактирования
// <EFBFBD> Состояние для multi-image editor (галерея)
// 📷 Показать встроенную камеру (без системного превью)
var showInAppCamera by remember { mutableStateOf(false) }
// 🖼 Состояние для multi-image editor (галерея)
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
// <20>📷 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(

View File

@@ -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)
) {

View File

@@ -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<ImageCapture?>(null) }
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(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<PreviewView?>(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))
}
}