feat: add in-app camera component for photo capture
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user