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.data.ForwardManager
|
||||||
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
|
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
|
||||||
import com.rosetta.messenger.utils.MediaUtils
|
import com.rosetta.messenger.utils.MediaUtils
|
||||||
|
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
|
||||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -181,23 +182,20 @@ fun ChatDetailScreen(
|
|||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
// <EFBFBD>📷 Camera: URI для сохранения фото
|
// 📷 Camera: URI для сохранения фото
|
||||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
|
||||||
|
var pendingCameraPhotoUri by remember { mutableStateOf<Uri?>(null) } // Фото для редактирования
|
||||||
|
|
||||||
// 📷 Camera launcher
|
// 📷 Camera launcher
|
||||||
val cameraLauncher = rememberLauncherForActivityResult(
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.TakePicture()
|
contract = ActivityResultContracts.TakePicture()
|
||||||
) { success ->
|
) { success ->
|
||||||
if (success && cameraImageUri != null) {
|
if (success && cameraImageUri != null) {
|
||||||
scope.launch {
|
android.util.Log.d("ChatDetailScreen", "📷 Photo captured: $cameraImageUri")
|
||||||
android.util.Log.d("ChatDetailScreen", "📷 Photo captured: $cameraImageUri")
|
// Открываем редактор вместо прямой отправки
|
||||||
// Конвертируем и отправляем
|
pendingCameraPhotoUri = cameraImageUri
|
||||||
val base64 = MediaUtils.uriToBase64Image(context, cameraImageUri!!)
|
|
||||||
val blurhash = MediaUtils.generateBlurhash(context, cameraImageUri!!)
|
|
||||||
if (base64 != null) {
|
|
||||||
viewModel.sendImageMessage(base64, blurhash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,8 +2002,9 @@ fun ChatDetailScreen(
|
|||||||
// Изображение
|
// Изображение
|
||||||
val base64 = MediaUtils.uriToBase64Image(context, item.uri)
|
val base64 = MediaUtils.uriToBase64Image(context, item.uri)
|
||||||
val blurhash = MediaUtils.generateBlurhash(context, item.uri)
|
val blurhash = MediaUtils.generateBlurhash(context, item.uri)
|
||||||
|
val (width, height) = MediaUtils.getImageDimensions(context, item.uri)
|
||||||
if (base64 != null) {
|
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()
|
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 imageBase64 Base64 изображения
|
||||||
* @param blurhash Blurhash preview изображения
|
* @param blurhash Blurhash preview изображения
|
||||||
* @param caption Подпись к изображению (опционально)
|
* @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 recipient = opponentKey
|
||||||
val sender = myPublicKey
|
val sender = myPublicKey
|
||||||
val privateKey = myPrivateKey
|
val privateKey = myPrivateKey
|
||||||
@@ -1451,7 +1453,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
id = "img_$timestamp",
|
id = "img_$timestamp",
|
||||||
type = AttachmentType.IMAGE,
|
type = AttachmentType.IMAGE,
|
||||||
preview = blurhash,
|
preview = blurhash,
|
||||||
blob = imageBase64 // Для локального отображения
|
blob = imageBase64, // Для локального отображения
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1493,7 +1497,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
id = attachmentId,
|
id = attachmentId,
|
||||||
blob = "", // 🔥 Пустой blob - файл на Transport Server!
|
blob = "", // 🔥 Пустой blob - файл на Transport Server!
|
||||||
type = AttachmentType.IMAGE,
|
type = AttachmentType.IMAGE,
|
||||||
preview = previewWithTag
|
preview = previewWithTag,
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
)
|
)
|
||||||
|
|
||||||
val packet = PacketMessage().apply {
|
val packet = PacketMessage().apply {
|
||||||
@@ -1533,6 +1539,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
put("type", AttachmentType.IMAGE.value)
|
put("type", AttachmentType.IMAGE.value)
|
||||||
put("preview", previewWithTag)
|
put("preview", previewWithTag)
|
||||||
put("blob", "") // Пустой blob - не сохраняем в БД!
|
put("blob", "") // Пустой blob - не сохраняем в БД!
|
||||||
|
put("width", width)
|
||||||
|
put("height", height)
|
||||||
})
|
})
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|
||||||
@@ -1566,14 +1574,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* @param images Список пар (base64, blurhash) для каждого изображения
|
* @param images Список пар (base64, blurhash) для каждого изображения
|
||||||
* @param caption Подпись к группе изображений (опционально)
|
* @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 = "") {
|
fun sendImageGroup(images: List<ImageData>, caption: String = "") {
|
||||||
if (images.isEmpty()) return
|
if (images.isEmpty()) return
|
||||||
|
|
||||||
// Если одно изображение - отправляем обычным способом
|
// Если одно изображение - отправляем обычным способом
|
||||||
if (images.size == 1) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,7 +1610,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
id = "img_${timestamp}_$index",
|
id = "img_${timestamp}_$index",
|
||||||
type = AttachmentType.IMAGE,
|
type = AttachmentType.IMAGE,
|
||||||
preview = imageData.blurhash,
|
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,
|
id = attachmentId,
|
||||||
blob = if (uploadTag != null) "" else encryptedImageBlob,
|
blob = if (uploadTag != null) "" else encryptedImageBlob,
|
||||||
type = AttachmentType.IMAGE,
|
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("type", AttachmentType.IMAGE.value)
|
||||||
put("preview", previewWithTag)
|
put("preview", previewWithTag)
|
||||||
put("blob", "") // Пустой blob - изображения в файловой системе
|
put("blob", "") // Пустой blob - изображения в файловой системе
|
||||||
|
put("width", imageData.width)
|
||||||
|
put("height", imageData.height)
|
||||||
})
|
})
|
||||||
|
|
||||||
Log.d(TAG, "🖼️ Image $index uploaded: tag=${uploadTag?.take(20) ?: "null"}")
|
Log.d(TAG, "🖼️ Image $index uploaded: tag=${uploadTag?.take(20) ?: "null"}")
|
||||||
|
|||||||
@@ -649,41 +649,58 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📐 Вычисляем размер пузырька на основе соотношения сторон изображения (как в Telegram)
|
// 📐 Вычисляем размер пузырька на основе соотношения сторон изображения (как в 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% контейнера
|
// Если fillMaxSize - используем 100% контейнера
|
||||||
if (fillMaxSize) {
|
if (fillMaxSize) {
|
||||||
null to null
|
null to null
|
||||||
} else {
|
} else {
|
||||||
val maxWidth = 260.dp
|
// Telegram-style размеры - больше и адаптивнее
|
||||||
val maxHeight = 340.dp
|
val maxWidth = 280.dp
|
||||||
val minWidth = 180.dp
|
val maxHeight = 400.dp // Увеличено для вертикальных фото
|
||||||
val minHeight = 140.dp
|
val minWidth = 160.dp
|
||||||
|
val minHeight = 120.dp
|
||||||
|
|
||||||
if (attachment.width > 0 && attachment.height > 0) {
|
if (actualWidth > 0 && actualHeight > 0) {
|
||||||
val ar = attachment.width.toFloat() / attachment.height.toFloat()
|
val ar = actualWidth.toFloat() / actualHeight.toFloat()
|
||||||
|
|
||||||
when {
|
when {
|
||||||
|
// Очень широкое изображение (panorama)
|
||||||
|
ar > 2.0f -> {
|
||||||
|
val width = maxWidth
|
||||||
|
val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight)
|
||||||
|
width to height
|
||||||
|
}
|
||||||
// Широкое изображение (landscape)
|
// Широкое изображение (landscape)
|
||||||
ar > 1.2f -> {
|
ar > 1.2f -> {
|
||||||
val width = maxWidth
|
val width = maxWidth
|
||||||
val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight)
|
val height = (maxWidth.value / ar).dp.coerceIn(minHeight, maxHeight)
|
||||||
width to height
|
width to height
|
||||||
}
|
}
|
||||||
|
// Очень высокое изображение (story-like)
|
||||||
|
ar < 0.5f -> {
|
||||||
|
val height = maxHeight
|
||||||
|
val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth)
|
||||||
|
width to height
|
||||||
|
}
|
||||||
// Высокое изображение (portrait)
|
// Высокое изображение (portrait)
|
||||||
ar < 0.8f -> {
|
ar < 0.85f -> {
|
||||||
val height = maxHeight
|
val height = maxHeight
|
||||||
val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth)
|
val width = (maxHeight.value * ar).dp.coerceIn(minWidth, maxWidth)
|
||||||
width to height
|
width to height
|
||||||
}
|
}
|
||||||
// Квадратное или близкое к квадрату
|
// Квадратное или близкое к квадрату
|
||||||
else -> {
|
else -> {
|
||||||
val size = 220.dp
|
val size = 240.dp
|
||||||
size to size
|
size to size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback если размеры не указаны
|
// Fallback если размеры не указаны - квадрат средний
|
||||||
220.dp to 220.dp
|
240.dp to 240.dp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,8 +331,19 @@ fun MessageBubble(
|
|||||||
message.replyData == null &&
|
message.replyData == null &&
|
||||||
message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE }
|
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
|
// Для сообщений только с фото - минимальный 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
|
val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -395,9 +406,56 @@ fun MessageBubble(
|
|||||||
currentUserPublicKey = currentUserPublicKey,
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
onImageClick = onImageClick
|
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 - текст слева, время справа на одной строке
|
// Если есть reply - текст слева, время справа на одной строке
|
||||||
@@ -438,8 +496,8 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!hasOnlyMedia && message.text.isNotEmpty()) {
|
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
|
||||||
// Без reply и не только фото - компактно в одну строку
|
// Без reply, не только фото, и не фото с caption - компактно в одну строку
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -83,7 +83,9 @@ fun ImageEditorScreen(
|
|||||||
imageUri: Uri,
|
imageUri: Uri,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (Uri) -> 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 context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -96,6 +98,9 @@ fun ImageEditorScreen(
|
|||||||
var showBrushSizeSlider by remember { mutableStateOf(false) }
|
var showBrushSizeSlider by remember { mutableStateOf(false) }
|
||||||
var isSaving by remember { mutableStateOf(false) }
|
var isSaving by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Caption state
|
||||||
|
var caption by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// Current image URI (can change after crop)
|
// Current image URI (can change after crop)
|
||||||
var currentImageUri by remember { mutableStateOf(imageUri) }
|
var currentImageUri by remember { mutableStateOf(imageUri) }
|
||||||
|
|
||||||
@@ -170,33 +175,35 @@ fun ImageEditorScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Done/Save button
|
// Done/Save button (only show if no caption input)
|
||||||
TextButton(
|
if (!showCaptionInput) {
|
||||||
onClick = {
|
TextButton(
|
||||||
scope.launch {
|
onClick = {
|
||||||
isSaving = true
|
scope.launch {
|
||||||
saveEditedImage(context, photoEditor) { savedUri ->
|
isSaving = true
|
||||||
isSaving = false
|
saveEditedImage(context, photoEditor) { savedUri ->
|
||||||
if (savedUri != null) {
|
isSaving = false
|
||||||
onSave(savedUri)
|
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
|
// Bottom toolbar with tools
|
||||||
BottomToolbar(
|
BottomToolbar(
|
||||||
currentTool = currentTool,
|
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
|
@Composable
|
||||||
private fun BottomToolbar(
|
private fun BottomToolbar(
|
||||||
currentTool: EditorTool,
|
currentTool: EditorTool,
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ fun MediaPickerBottomSheet(
|
|||||||
// Editor state - when user taps on a photo, open editor
|
// Editor state - when user taps on a photo, open editor
|
||||||
var editingItem by remember { mutableStateOf<MediaItem?>(null) }
|
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
|
// Permission launcher
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestMultiplePermissions()
|
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||||
@@ -148,8 +157,8 @@ fun MediaPickerBottomSheet(
|
|||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
|
|
||||||
// Show gallery only if not editing
|
// Show gallery only if not editing and not in preview
|
||||||
if (isVisible && editingItem == null) {
|
if (isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
containerColor = backgroundColor,
|
containerColor = backgroundColor,
|
||||||
@@ -318,23 +327,59 @@ fun MediaPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image Editor overlay
|
// Image Editor overlay для фото из галереи
|
||||||
editingItem?.let { item ->
|
editingItem?.let { item ->
|
||||||
ImageEditorScreen(
|
ImageEditorScreen(
|
||||||
imageUri = item.uri,
|
imageUri = item.uri,
|
||||||
onDismiss = { editingItem = null },
|
onDismiss = { editingItem = null },
|
||||||
onSave = { editedUri ->
|
onSave = { editedUri ->
|
||||||
editingItem = null
|
editingItem = null
|
||||||
// Create a new MediaItem with the edited URI
|
// После редактирования открываем предпросмотр с caption
|
||||||
val editedItem = MediaItem(
|
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(),
|
id = System.currentTimeMillis(),
|
||||||
uri = editedUri,
|
uri = uri,
|
||||||
mimeType = "image/png",
|
mimeType = "image/png",
|
||||||
dateModified = System.currentTimeMillis()
|
dateModified = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
onMediaSelected(listOf(editedItem))
|
// Отправляем фото (caption можно передать отдельно если нужно)
|
||||||
|
onMediaSelected(listOf(item))
|
||||||
|
previewPhotoUri = null
|
||||||
|
photoCaption = ""
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
|
onDismiss = {
|
||||||
|
previewPhotoUri = null
|
||||||
|
photoCaption = ""
|
||||||
|
},
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -890,3 +935,113 @@ private fun formatDuration(durationMs: Long): String {
|
|||||||
val seconds = totalSeconds % 60
|
val seconds = totalSeconds % 60
|
||||||
return "%d:%02d".format(minutes, seconds)
|
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