From 2605035c26cc7a3d078ae13785508879356b59e9 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 25 Jan 2026 19:37:24 +0500 Subject: [PATCH] refactor: Remove placeholder buttons for location and contact in QuickActionsRow --- app/build.gradle.kts | 3 + .../ui/chats/components/ImageEditorScreen.kt | 509 ++++++++++++++++++ .../components/MediaPickerBottomSheet.kt | 62 ++- 3 files changed, 553 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e0586e1..61bd196 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -96,6 +96,9 @@ dependencies { // uCrop for image cropping implementation("com.github.yalantis:ucrop:2.2.8") + // PhotoEditor for drawing, filters, text on images + implementation("com.burhanrashid52:photoeditor:3.0.2") + // Blurhash for image placeholders implementation("com.vanniktech:blurhash:0.1.0") diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt new file mode 100644 index 0000000..5693ab7 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -0,0 +1,509 @@ +package com.rosetta.messenger.ui.chats.components + +import android.content.Context +import android.net.Uri +import android.util.Log +import android.widget.ImageView +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +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.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import compose.icons.TablerIcons +import compose.icons.tablericons.* +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import ja.burhanrashid52.photoeditor.PhotoEditor +import ja.burhanrashid52.photoeditor.PhotoEditorView +import ja.burhanrashid52.photoeditor.SaveSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +private const val TAG = "ImageEditorScreen" + +/** + * Available editing tools + */ +enum class EditorTool { + NONE, + DRAW +} + +/** + * Drawing colors + */ +val drawingColors = listOf( + Color.White, + Color.Black, + Color.Red, + Color(0xFFFF9500), // Orange + Color.Yellow, + Color(0xFF34C759), // Green + Color(0xFF007AFF), // Blue + Color(0xFF5856D6), // Purple + Color(0xFFFF2D55), // Pink +) + +/** + * Telegram-style image editor screen + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImageEditorScreen( + imageUri: Uri, + onDismiss: () -> Unit, + onSave: (Uri) -> Unit, + isDarkTheme: Boolean = true +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Editor state + var currentTool by remember { mutableStateOf(EditorTool.NONE) } + var selectedColor by remember { mutableStateOf(Color.White) } + var brushSize by remember { mutableStateOf(10f) } + var showColorPicker by remember { mutableStateOf(false) } + var showBrushSizeSlider by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } + + // PhotoEditor reference + var photoEditor by remember { mutableStateOf(null) } + + BackHandler { + onDismiss() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Top toolbar + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + TablerIcons.X, + contentDescription = "Close", + tint = Color.White + ) + } + }, + actions = { + // Undo + IconButton( + onClick = { photoEditor?.undo() } + ) { + Icon( + TablerIcons.ArrowBackUp, + contentDescription = "Undo", + tint = Color.White + ) + } + + // Redo + IconButton( + onClick = { photoEditor?.redo() } + ) { + Icon( + TablerIcons.ArrowForwardUp, + contentDescription = "Redo", + tint = Color.White + ) + } + + // Done/Save button + 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 + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + + // Photo Editor View + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + AndroidView( + factory = { ctx -> + PhotoEditorView(ctx).apply { + // Load image - fullscreen + source.setImageURI(imageUri) + source.scaleType = ImageView.ScaleType.CENTER_CROP + + // Build PhotoEditor + photoEditor = PhotoEditor.Builder(ctx, this) + .setPinchTextScalable(true) + .setClipSourceImage(true) + .build() + } + }, + modifier = Modifier.fillMaxSize() + ) + } + + // Color picker bar (when drawing) + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW && showColorPicker, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + ColorPickerBar( + selectedColor = selectedColor, + onColorSelected = { color -> + selectedColor = color + photoEditor?.brushColor = color.toArgb() + } + ) + } + + // Brush size slider + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW && showBrushSizeSlider, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + BrushSizeBar( + brushSize = brushSize, + onBrushSizeChanged = { size -> + brushSize = size + photoEditor?.brushSize = size + }, + selectedColor = selectedColor + ) + } + + // Bottom toolbar with tools + BottomToolbar( + currentTool = currentTool, + onToolSelected = { tool -> + when (tool) { + EditorTool.DRAW -> { + if (currentTool == EditorTool.DRAW) { + showColorPicker = !showColorPicker + showBrushSizeSlider = false + } else { + currentTool = tool + photoEditor?.setBrushDrawingMode(true) + photoEditor?.brushColor = selectedColor.toArgb() + photoEditor?.brushSize = brushSize + showColorPicker = true + } + } + else -> { + currentTool = EditorTool.NONE + showColorPicker = false + showBrushSizeSlider = false + photoEditor?.setBrushDrawingMode(false) + } + } + }, + onBrushSizeClick = { + showBrushSizeSlider = !showBrushSizeSlider + showColorPicker = false + }, + onEraserClick = { + photoEditor?.brushEraser() + } + ) + } + } +} + +@Composable +private fun BottomToolbar( + currentTool: EditorTool, + onToolSelected: (EditorTool) -> Unit, + onBrushSizeClick: () -> Unit, + onEraserClick: () -> Unit +) { + Surface( + color = Color(0xFF1C1C1E), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .navigationBarsPadding(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + // Draw tool + ToolButton( + icon = TablerIcons.Pencil, + label = "Draw", + isSelected = currentTool == EditorTool.DRAW, + onClick = { onToolSelected(EditorTool.DRAW) } + ) + + // Eraser (when drawing) + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + ToolButton( + icon = TablerIcons.Eraser, + label = "Eraser", + isSelected = false, + onClick = onEraserClick + ) + } + + // Brush size (when drawing) + AnimatedVisibility( + visible = currentTool == EditorTool.DRAW, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + ToolButton( + icon = TablerIcons.Circle, + label = "Size", + isSelected = false, + onClick = onBrushSizeClick + ) + } + } + } +} + +@Composable +private fun ToolButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ) + .padding(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isSelected) PrimaryBlue else Color.White, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + color = if (isSelected) PrimaryBlue else Color.White.copy(alpha = 0.7f), + fontSize = 10.sp + ) + } +} + +@Composable +private fun ColorPickerBar( + selectedColor: Color, + onColorSelected: (Color) -> Unit +) { + Surface( + color = Color(0xFF2C2C2E), + modifier = Modifier.fillMaxWidth() + ) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + items(drawingColors) { color -> + ColorButton( + color = color, + isSelected = color == selectedColor, + onClick = { onColorSelected(color) } + ) + } + } + } +} + +@Composable +private fun ColorButton( + color: Color, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .then( + if (isSelected) { + Modifier.border(3.dp, Color.White, CircleShape) + } else { + Modifier.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape) + } + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = if (color == Color.White) Color.Black else Color.White, + modifier = Modifier.size(16.dp) + ) + } + } +} + +@Composable +private fun BrushSizeBar( + brushSize: Float, + onBrushSizeChanged: (Float) -> Unit, + selectedColor: Color +) { + Surface( + color = Color(0xFF2C2C2E), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Min indicator + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(selectedColor) + ) + + // Slider + Slider( + value = brushSize, + onValueChange = onBrushSizeChanged, + valueRange = 5f..50f, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + colors = SliderDefaults.colors( + thumbColor = selectedColor, + activeTrackColor = selectedColor + ) + ) + + // Max indicator + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(selectedColor) + ) + } + } +} + +/** + * Save edited image and return the URI + */ +private suspend fun saveEditedImage( + context: Context, + photoEditor: PhotoEditor?, + onResult: (Uri?) -> Unit +) { + if (photoEditor == null) { + onResult(null) + return + } + + withContext(Dispatchers.IO) { + try { + val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") + + val saveSettings = SaveSettings.Builder() + .setClearViewsEnabled(false) + .setTransparencyEnabled(true) + .build() + + photoEditor.saveAsFile( + file.absolutePath, + saveSettings, + object : PhotoEditor.OnSaveListener { + override fun onSuccess(imagePath: String) { + Log.d(TAG, "Image saved to: $imagePath") + onResult(Uri.fromFile(File(imagePath))) + } + + override fun onFailure(exception: Exception) { + Log.e(TAG, "Failed to save image", exception) + onResult(null) + } + } + ) + } catch (e: Exception) { + Log.e(TAG, "Error saving image", e) + withContext(Dispatchers.Main) { + onResult(null) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index fb44e65..4f4404d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -85,6 +85,9 @@ fun MediaPickerBottomSheet( // Selected items var selectedItems by remember { mutableStateOf>(emptySet()) } + // Editor state - when user taps on a photo, open editor + var editingItem by remember { mutableStateOf(null) } + // Permission launcher val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() @@ -253,6 +256,23 @@ fun MediaPickerBottomSheet( mediaItems = mediaItems, selectedItems = selectedItems, onItemClick = { item -> + // Single tap - open editor for images, or select for videos + if (!item.isVideo) { + // Open image editor + editingItem = item + } else { + // For videos - just toggle selection + selectedItems = if (item.id in selectedItems) { + selectedItems - item.id + } else if (selectedItems.size < maxSelection) { + selectedItems + item.id + } else { + selectedItems + } + } + }, + onItemLongClick = { item -> + // Long press - toggle selection (multi-select mode) selectedItems = if (item.id in selectedItems) { selectedItems - item.id } else if (selectedItems.size < maxSelection) { @@ -261,9 +281,6 @@ fun MediaPickerBottomSheet( selectedItems } }, - onItemLongClick = { item -> - // TODO: Preview image - }, isDarkTheme = isDarkTheme, modifier = Modifier.weight(1f) ) @@ -274,6 +291,27 @@ fun MediaPickerBottomSheet( } } } + + // 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( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelected(listOf(editedItem)) + onDismiss() + }, + isDarkTheme = isDarkTheme + ) + } } @Composable @@ -363,24 +401,6 @@ private fun QuickActionsRow( iconColor = iconColor, onClick = onFileClick ) - - // Location button (placeholder) - QuickActionButton( - icon = TablerIcons.MapPin, - label = "Location", - backgroundColor = buttonColor, - iconColor = iconColor, - onClick = { /* TODO */ } - ) - - // Contact button (placeholder) - QuickActionButton( - icon = TablerIcons.User, - label = "Contact", - backgroundColor = buttonColor, - iconColor = iconColor, - onClick = { /* TODO */ } - ) } }