feat: Add photo preview with caption functionality in MediaPickerBottomSheet

This commit is contained in:
2026-01-30 01:05:30 +05:00
parent 8c30fc3549
commit 6720057ebc
7 changed files with 508 additions and 67 deletions

View File

@@ -72,6 +72,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
import com.rosetta.messenger.utils.MediaUtils
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.compose.runtime.collectAsState
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -181,23 +182,20 @@ fun ChatDetailScreen(
var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableStateOf(0) }
// <EFBFBD>📷 Camera: URI для сохранения фото
// 📷 Camera: URI для сохранения фото
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember { mutableStateOf<Uri?>(null) } // Фото для редактирования
// 📷 Camera launcher
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success && cameraImageUri != null) {
scope.launch {
android.util.Log.d("ChatDetailScreen", "📷 Photo captured: $cameraImageUri")
// Конвертируем и отправляем
val base64 = MediaUtils.uriToBase64Image(context, cameraImageUri!!)
val blurhash = MediaUtils.generateBlurhash(context, cameraImageUri!!)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash)
}
}
// Открываем редактор вместо прямой отправки
pendingCameraPhotoUri = cameraImageUri
}
}
@@ -2004,8 +2002,9 @@ fun ChatDetailScreen(
// Изображение
val base64 = MediaUtils.uriToBase64Image(context, item.uri)
val blurhash = MediaUtils.generateBlurhash(context, item.uri)
val (width, height) = MediaUtils.getImageDimensions(context, item.uri)
if (base64 != null) {
imageDataList.add(ChatViewModel.ImageData(base64, blurhash))
imageDataList.add(ChatViewModel.ImageData(base64, blurhash, width, height))
}
}
}
@@ -2044,4 +2043,39 @@ fun ChatDetailScreen(
viewModel.sendAvatarMessage()
}
)
// 📷 Image Editor для фото с камеры (с caption как в Telegram)
pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen(
imageUri = uri,
onDismiss = {
pendingCameraPhotoUri = null
},
onSave = { editedUri ->
// Fallback если onSaveWithCaption не сработал
pendingCameraPhotoUri = null
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, editedUri)
val blurhash = MediaUtils.generateBlurhash(context, editedUri)
val (width, height) = MediaUtils.getImageDimensions(context, editedUri)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, "", width, height)
}
}
},
onSaveWithCaption = { editedUri, caption ->
pendingCameraPhotoUri = null
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, editedUri)
val blurhash = MediaUtils.generateBlurhash(context, editedUri)
val (width, height) = MediaUtils.getImageDimensions(context, editedUri)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, caption, width, height)
}
}
},
isDarkTheme = isDarkTheme,
showCaptionInput = true
)
}
}

View File

@@ -1418,8 +1418,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* @param imageBase64 Base64 изображения
* @param blurhash Blurhash preview изображения
* @param caption Подпись к изображению (опционально)
* @param width Ширина изображения (для правильного отображения)
* @param height Высота изображения (для правильного отображения)
*/
fun sendImageMessage(imageBase64: String, blurhash: String, caption: String = "") {
fun sendImageMessage(imageBase64: String, blurhash: String, caption: String = "", width: Int = 0, height: Int = 0) {
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
@@ -1451,7 +1453,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = "img_$timestamp",
type = AttachmentType.IMAGE,
preview = blurhash,
blob = imageBase64 // Для локального отображения
blob = imageBase64, // Для локального отображения
width = width,
height = height
)
)
)
@@ -1493,7 +1497,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.IMAGE,
preview = previewWithTag
preview = previewWithTag,
width = width,
height = height
)
val packet = PacketMessage().apply {
@@ -1533,6 +1539,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("blob", "") // Пустой blob - не сохраняем в БД!
put("width", width)
put("height", height)
})
}.toString()
@@ -1566,14 +1574,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* @param images Список пар (base64, blurhash) для каждого изображения
* @param caption Подпись к группе изображений (опционально)
*/
data class ImageData(val base64: String, val blurhash: String)
data class ImageData(val base64: String, val blurhash: String, val width: Int = 0, val height: Int = 0)
fun sendImageGroup(images: List<ImageData>, caption: String = "") {
if (images.isEmpty()) return
// Если одно изображение - отправляем обычным способом
if (images.size == 1) {
sendImageMessage(images[0].base64, images[0].blurhash, caption)
sendImageMessage(images[0].base64, images[0].blurhash, caption, images[0].width, images[0].height)
return
}
@@ -1602,7 +1610,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = "img_${timestamp}_$index",
type = AttachmentType.IMAGE,
preview = imageData.blurhash,
blob = imageData.base64 // Для локального отображения
blob = imageData.base64, // Для локального отображения
width = imageData.width,
height = imageData.height
)
}
@@ -1662,7 +1672,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attachmentId,
blob = if (uploadTag != null) "" else encryptedImageBlob,
type = AttachmentType.IMAGE,
preview = previewWithTag
preview = previewWithTag,
width = imageData.width,
height = imageData.height
))
// Для БД
@@ -1671,6 +1683,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("blob", "") // Пустой blob - изображения в файловой системе
put("width", imageData.width)
put("height", imageData.height)
})
Log.d(TAG, "🖼️ Image $index uploaded: tag=${uploadTag?.take(20) ?: "null"}")

View File

@@ -649,41 +649,58 @@ fun ImageAttachment(
}
// 📐 Вычисляем размер пузырька на основе соотношения сторон изображения (как в Telegram)
val (imageWidth, imageHeight) = remember(attachment.width, attachment.height, fillMaxSize, aspectRatio) {
// Используем размеры из attachment или из загруженного bitmap
val actualWidth = if (attachment.width > 0) attachment.width else imageBitmap?.width ?: 0
val actualHeight = if (attachment.height > 0) attachment.height else imageBitmap?.height ?: 0
val (imageWidth, imageHeight) = remember(actualWidth, actualHeight, fillMaxSize, aspectRatio) {
// Если fillMaxSize - используем 100% контейнера
if (fillMaxSize) {
null to null
} else {
val maxWidth = 260.dp
val maxHeight = 340.dp
val minWidth = 180.dp
val minHeight = 140.dp
// Telegram-style размеры - больше и адаптивнее
val maxWidth = 280.dp
val maxHeight = 400.dp // Увеличено для вертикальных фото
val minWidth = 160.dp
val minHeight = 120.dp
if (attachment.width > 0 && attachment.height > 0) {
val ar = attachment.width.toFloat() / attachment.height.toFloat()
if (actualWidth > 0 && actualHeight > 0) {
val ar = actualWidth.toFloat() / actualHeight.toFloat()
when {
// Очень широкое изображение (panorama)
ar > 2.0f -> {
val width = maxWidth
val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight)
width to height
}
// Широкое изображение (landscape)
ar > 1.2f -> {
val width = maxWidth
val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight)
width to height
}
// Очень высокое изображение (story-like)
ar < 0.5f -> {
val height = maxHeight
val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth)
width to height
}
// Высокое изображение (portrait)
ar < 0.8f -> {
ar < 0.85f -> {
val height = maxHeight
val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth)
width to height
}
// Квадратное или близкое к квадрату
else -> {
val size = 220.dp
val size = 240.dp
size to size
}
}
} else {
// Fallback если размеры не указаны
220.dp to 220.dp
// Fallback если размеры не указаны - квадрат средний
240.dp to 240.dp
}
}
}

View File

@@ -331,8 +331,19 @@ fun MessageBubble(
message.replyData == null &&
message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE }
// Фото + caption (как в Telegram)
val hasImageWithCaption = message.attachments.isNotEmpty() &&
message.text.isNotEmpty() &&
message.replyData == null &&
message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE }
// Для сообщений только с фото - минимальный padding и тонкий border
val bubblePadding = if (hasOnlyMedia) PaddingValues(0.dp) else PaddingValues(horizontal = 10.dp, vertical = 8.dp)
// Для фото + caption - padding только внизу для текста
val bubblePadding = when {
hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
}
val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp
Box(
@@ -395,9 +406,56 @@ fun MessageBubble(
currentUserPublicKey = currentUserPublicKey,
onImageClick = onImageClick
)
if (message.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
}
// 🖼️ Caption под фото (как в Telegram)
if (hasImageWithCaption) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(bubbleColor)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth()
) {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 16.sp,
modifier = Modifier.weight(1f, fill = false)
)
Spacer(modifier = Modifier.width(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(bottom = 2.dp)
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status
AnimatedMessageStatus(
status = displayStatus,
timeColor = timeColor,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
}
}
} else if (message.attachments.isNotEmpty() && message.text.isNotEmpty()) {
// Обычное фото + текст (не только изображения)
Spacer(modifier = Modifier.height(8.dp))
}
// Если есть reply - текст слева, время справа на одной строке
@@ -438,8 +496,8 @@ fun MessageBubble(
}
}
}
} else if (!hasOnlyMedia && message.text.isNotEmpty()) {
// Без reply и не только фото - компактно в одну строку
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
// Без reply, не только фото, и не фото с caption - компактно в одну строку
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),

View File

@@ -75,7 +75,7 @@ val drawingColors = listOf(
)
/**
* Telegram-style image editor screen
* Telegram-style image editor screen with caption input
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -83,7 +83,9 @@ fun ImageEditorScreen(
imageUri: Uri,
onDismiss: () -> Unit,
onSave: (Uri) -> Unit,
isDarkTheme: Boolean = true
onSaveWithCaption: ((Uri, String) -> Unit)? = null, // New callback with caption
isDarkTheme: Boolean = true,
showCaptionInput: Boolean = false // Show caption input for camera flow
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -96,6 +98,9 @@ fun ImageEditorScreen(
var showBrushSizeSlider by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
// Caption state
var caption by remember { mutableStateOf("") }
// Current image URI (can change after crop)
var currentImageUri by remember { mutableStateOf(imageUri) }
@@ -170,7 +175,8 @@ fun ImageEditorScreen(
)
}
// Done/Save button
// Done/Save button (only show if no caption input)
if (!showCaptionInput) {
TextButton(
onClick = {
scope.launch {
@@ -199,6 +205,7 @@ fun ImageEditorScreen(
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
@@ -282,6 +289,30 @@ fun ImageEditorScreen(
)
}
// Caption input bar (for camera flow like Telegram) - ВЫШЕ иконок
if (showCaptionInput) {
CaptionInputBar(
caption = caption,
onCaptionChange = { caption = it },
isSaving = isSaving,
onSend = {
scope.launch {
isSaving = true
saveEditedImage(context, photoEditor) { savedUri ->
isSaving = false
if (savedUri != null) {
if (onSaveWithCaption != null) {
onSaveWithCaption(savedUri, caption)
} else {
onSave(savedUri)
}
}
}
}
}
)
}
// Bottom toolbar with tools
BottomToolbar(
currentTool = currentTool,
@@ -342,6 +373,77 @@ fun ImageEditorScreen(
}
}
/**
* Caption input bar with send button (like Telegram)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CaptionInputBar(
caption: String,
onCaptionChange: (String) -> Unit,
isSaving: Boolean,
onSend: () -> Unit
) {
Surface(
color = Color(0xFF1C1C1E),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Caption text field
OutlinedTextField(
value = caption,
onValueChange = onCaptionChange,
modifier = Modifier.weight(1f),
placeholder = {
Text(
"Add a caption...",
color = Color.White.copy(alpha = 0.5f)
)
},
maxLines = 3,
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = PrimaryBlue,
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = Color.White.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(20.dp)
)
// Send button
FloatingActionButton(
onClick = onSend,
containerColor = PrimaryBlue,
modifier = Modifier.size(48.dp),
elevation = FloatingActionButtonDefaults.elevation(0.dp)
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
}
}
}
}
@Composable
private fun BottomToolbar(
currentTool: EditorTool,

View File

@@ -96,6 +96,15 @@ fun MediaPickerBottomSheet(
// Editor state - when user taps on a photo, open editor
var editingItem by remember { mutableStateOf<MediaItem?>(null) }
// Uri фото, только что сделанного с камеры (для редактирования)
var pendingPhotoUri by remember { mutableStateOf<Uri?>(null) }
// Uri отредактированного фото (для предпросмотра с caption)
var previewPhotoUri by remember { mutableStateOf<Uri?>(null) }
// Caption для фото
var photoCaption by remember { mutableStateOf("") }
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
@@ -148,8 +157,8 @@ fun MediaPickerBottomSheet(
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
// Show gallery only if not editing
if (isVisible && editingItem == null) {
// Show gallery only if not editing and not in preview
if (isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = backgroundColor,
@@ -318,23 +327,59 @@ fun MediaPickerBottomSheet(
}
}
// Image Editor overlay
// Image Editor overlay для фото из галереи
editingItem?.let { item ->
ImageEditorScreen(
imageUri = item.uri,
onDismiss = { editingItem = null },
onSave = { editedUri ->
editingItem = null
// Create a new MediaItem with the edited URI
val editedItem = MediaItem(
// После редактирования открываем предпросмотр с caption
previewPhotoUri = editedUri
},
isDarkTheme = isDarkTheme
)
}
// Image Editor overlay для фото с камеры
pendingPhotoUri?.let { uri ->
ImageEditorScreen(
imageUri = uri,
onDismiss = {
pendingPhotoUri = null
},
onSave = { editedUri ->
pendingPhotoUri = null
// После редактирования открываем предпросмотр с caption
previewPhotoUri = editedUri
},
isDarkTheme = isDarkTheme
)
}
// Preview with caption before sending (как в Telegram)
previewPhotoUri?.let { uri ->
PhotoPreviewWithCaptionScreen(
imageUri = uri,
caption = photoCaption,
onCaptionChange = { photoCaption = it },
onSend = {
val item = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
uri = uri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(editedItem))
// Отправляем фото (caption можно передать отдельно если нужно)
onMediaSelected(listOf(item))
previewPhotoUri = null
photoCaption = ""
onDismiss()
},
onDismiss = {
previewPhotoUri = null
photoCaption = ""
},
isDarkTheme = isDarkTheme
)
}
@@ -890,3 +935,113 @@ private fun formatDuration(durationMs: Long): String {
val seconds = totalSeconds % 60
return "%d:%02d".format(minutes, seconds)
}
/**
* Экран предпросмотра фото с caption и кнопкой отправки (как в Telegram)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhotoPreviewWithCaptionScreen(
imageUri: Uri,
caption: String,
onCaptionChange: (String) -> Unit,
onSend: () -> Unit,
onDismiss: () -> Unit,
isDarkTheme: Boolean
) {
val backgroundColor = if (isDarkTheme) Color.Black else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
Surface(
color = backgroundColor,
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
// Top bar with close button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = textColor
)
}
}
// Image preview
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUri)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
// Caption input
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = caption,
onValueChange = onCaptionChange,
modifier = Modifier.weight(1f),
placeholder = {
Text(
"Add a caption...",
color = textColor.copy(alpha = 0.5f)
)
},
maxLines = 3,
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
cursorColor = PrimaryBlue,
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = textColor.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(20.dp)
)
// Send button
FloatingActionButton(
onClick = onSend,
containerColor = PrimaryBlue,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = TablerIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View File

@@ -216,4 +216,65 @@ object MediaUtils {
""
}
}
/**
* Получить размеры изображения из Uri без полной загрузки в память
*/
fun getImageDimensions(context: Context, uri: Uri): Pair<Int, Int> {
return try {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true // Не загружаем в память, только размеры
}
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, options)
}
var width = options.outWidth
var height = options.outHeight
// Учитываем масштабирование (как в uriToBase64Image)
if (width > MAX_IMAGE_SIZE || height > MAX_IMAGE_SIZE) {
val ratio = width.toFloat() / height.toFloat()
if (width > height) {
width = MAX_IMAGE_SIZE
height = (MAX_IMAGE_SIZE / ratio).toInt()
} else {
height = MAX_IMAGE_SIZE
width = (MAX_IMAGE_SIZE * ratio).toInt()
}
}
Log.d(TAG, "📐 Image dimensions: ${width}x${height}")
Pair(width, height)
} catch (e: Exception) {
Log.e(TAG, "📐 Failed to get image dimensions", e)
Pair(0, 0)
}
}
/**
* Получить размеры изображения из Base64 строки
*/
fun getImageDimensionsFromBase64(base64: String): Pair<Int, Int> {
return try {
// Убираем data URI prefix если есть
val base64Data = if (base64.contains(",")) {
base64.substringAfter(",")
} else {
base64
}
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
Log.d(TAG, "📐 Image dimensions from base64: ${options.outWidth}x${options.outHeight}")
Pair(options.outWidth, options.outHeight)
} catch (e: Exception) {
Log.e(TAG, "📐 Failed to get image dimensions from base64", e)
Pair(0, 0)
}
}
}