feat: Add photo preview with caption functionality in MediaPickerBottomSheet
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
android.util.Log.d("ChatDetailScreen", "📷 Photo captured: $cameraImageUri")
|
||||
// Открываем редактор вместо прямой отправки
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,33 +175,35 @@ fun ImageEditorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Done/Save button
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isSaving = true
|
||||
saveEditedImage(context, photoEditor) { savedUri ->
|
||||
isSaving = false
|
||||
if (savedUri != null) {
|
||||
onSave(savedUri)
|
||||
// Done/Save button (only show if no caption input)
|
||||
if (!showCaptionInput) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isSaving = true
|
||||
saveEditedImage(context, photoEditor) { savedUri ->
|
||||
isSaving = false
|
||||
if (savedUri != null) {
|
||||
onSave(savedUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isSaving
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = PrimaryBlue,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Done",
|
||||
color = PrimaryBlue,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = !isSaving
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = PrimaryBlue,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Done",
|
||||
color = PrimaryBlue,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user