diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index b4578c1e32..935cf617ac 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -76,6 +76,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { QualityHigh(icon(R.drawable.symbol_quality_high_24)), QualityHighSlash(icon(R.drawable.symbol_quality_high_slash_24)), Recent(icon(R.drawable.symbol_recent_24)), + Redo(icon(R.drawable.symbol_redo_24)), Save(icon(R.drawable.symbol_save_android_24)), Search(icon(R.drawable.symbol_search_24)), Settings(icon(R.drawable.symbol_settings_android_24)), @@ -86,6 +87,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { Transfer(icon(R.drawable.symbol_transfer_24)), TransferDisplay(icon(R.drawable.symbol_transfer_display_48)), Trash(icon(R.drawable.symbol_trash_24)), + Undo(icon(R.drawable.symbol_undo_24)), X(icon(R.drawable.symbol_x_24)), XCircleFill(icon(R.drawable.symbol_x_circle_fill_24)) } diff --git a/core/ui/src/main/res/drawable/symbol_redo_24.xml b/core/ui/src/main/res/drawable/symbol_redo_24.xml new file mode 100644 index 0000000000..71aa453290 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_redo_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/symbol_undo_24.xml b/core/ui/src/main/res/drawable/symbol_undo_24.xml new file mode 100644 index 0000000000..b76443f9d0 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_undo_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt index a4385c6e7d..cd930f89d2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt @@ -5,6 +5,10 @@ package org.signal.mediasend +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -12,4 +16,7 @@ import androidx.compose.ui.unit.dp object MediaSendMetrics { val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp) val SelectedMediaPreviewShape = RoundedCornerShape(8.dp) + + val ControlEnterTransition: EnterTransition = fadeIn() + val ControlExitTransition: ExitTransition = fadeOut() } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt index 10bfccfc9a..efccd93ae9 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt @@ -32,7 +32,7 @@ import org.signal.mediasend.edit.image.ImageEditorState */ @Stable class ImageController @RememberInComposition constructor( - val editorModel: EditorModel, + private val editorModel: EditorModel, private val brushWidths: BrushWidthsState = BrushWidthsState() ) { @@ -190,6 +190,20 @@ class ImageController @RememberInComposition constructor( } } + fun undo() { + editorModel.undo() + } + + fun redo() { + editorModel.redo() + } + + fun clearAllEdits() { + while (imageEditorState.undoAvailable) { + editorModel.undo() + } + } + fun setDrawColor(color: Int) { imageEditorState.drawColor = brushTool?.applyAlpha(color) ?: color } @@ -321,6 +335,7 @@ class ImageController @RememberInComposition constructor( } fun onDialGestureStart() { + imageEditorState.isGestureActive = true val mainImage = editorModel.mainImage ?: return initialDialScale = mainImage.localScaleX minDialScaleDown = 1f @@ -334,6 +349,7 @@ class ImageController @RememberInComposition constructor( } fun onDialGestureEnd() { + imageEditorState.isGestureActive = false val mainImage = editorModel.mainImage ?: return mainImage.commitEditorMatrix() editorModel.postEdit(true) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index ebde3bdbb9..bd573514b8 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -58,7 +58,9 @@ import org.signal.mediasend.edit.image.BrushWidthPreview import org.signal.mediasend.edit.image.BrushWidthsState import org.signal.mediasend.edit.image.DrawModeColorBar import org.signal.mediasend.edit.image.ImageEditor +import org.signal.mediasend.edit.image.ImageEditorClearAllButton import org.signal.mediasend.edit.image.ImageEditorToolbar +import org.signal.mediasend.edit.image.ImageEditorUndoRedoButtons import org.signal.mediasend.edit.image.RotationDial import org.signal.mediasend.edit.video.VideoEditorFragment import org.signal.mediasend.edit.video.VideoEditorToolbar @@ -353,6 +355,22 @@ fun MediaEditScreen( .systemBarsPadding() ) + ImageEditorUndoRedoButtons( + imageEditorController = imageController, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 12.dp, start = 16.dp) + .systemBarsPadding() + ) + + ImageEditorClearAllButton( + imageEditorController = imageController, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 12.dp, end = 16.dp) + .systemBarsPadding() + ) + if (state.isSavingMedia) { MediaEditScreenDialogs.SavingToStorageProgressDialog() } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt index 9be8c5b7ce..f7e65ce7c5 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt @@ -143,37 +143,42 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller } down.consume() + state.isGestureActive = true var previousPointerCount = 1 - while (true) { - val event = awaitPointerEvent() - val currentPressed = event.changes.filter { it.pressed } - val currentCount = currentPressed.size + try { + while (true) { + val event = awaitPointerEvent() + val currentPressed = event.changes.filter { it.pressed } + val currentCount = currentPressed.size + + if (currentCount == 0) { + event.changes.forEach { it.consume() } + touchHandler.onUp(state.editorModel) + state.onGestureCompleted?.invoke() + break + } + + if (currentCount == 2 && previousPointerCount < 2) { + val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last() + val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1) + touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex) + } else if (currentCount == 1 && previousPointerCount == 2) { + val released = event.changes.firstOrNull { !it.pressed && it.previousPressed } + val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0 + touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex) + } else if (touchHandler.hasActiveSession()) { + val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray() + touchHandler.onMove(state.editorModel, pointers) + state.invalidate() + } - if (currentCount == 0) { event.changes.forEach { it.consume() } - touchHandler.onUp(state.editorModel) - state.onGestureCompleted?.invoke() - break + previousPointerCount = currentCount } - - if (currentCount == 2 && previousPointerCount < 2) { - val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last() - val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1) - touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex) - } else if (currentCount == 1 && previousPointerCount == 2) { - val released = event.changes.firstOrNull { !it.pressed && it.previousPressed } - val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0 - touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex) - } else if (touchHandler.hasActiveSession()) { - val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray() - touchHandler.onMove(state.editorModel, pointers) - state.invalidate() - } - - event.changes.forEach { it.consume() } - previousPointerCount = currentCount + } finally { + state.isGestureActive = false } } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt new file mode 100644 index 0000000000..4eb882d77e --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.R +import org.signal.mediasend.edit.ImageController + +@Composable +internal fun ImageEditorClearAllButton( + imageEditorController: ImageController?, + modifier: Modifier = Modifier, + canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false +) { + ImageEditorGestureAwareControl( + imageEditorController = imageEditorController, + modifier = modifier, + extraCheck = { canUndo } + ) { + Buttons.MediumTonal( + colors = ButtonDefaults.buttonColors( + containerColor = SignalTheme.colors.colorSurface5, + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = { + imageEditorController?.clearAllEdits() + } + ) { + Text(text = stringResource(R.string.ImageEditorClearAllButton__clear_all)) + } + } +} + +@DayNightPreviews +@Composable +private fun ImageEditorClearAllButtonPreview() { + Previews.Preview { + ImageEditorClearAllButton( + imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } }, + canUndo = true + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt new file mode 100644 index 0000000000..13feb0f764 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.signal.mediasend.MediaSendMetrics +import org.signal.mediasend.edit.ImageController + +/** + * Hides or shows content based on whether the user is performing a gesture. + */ +@Composable +fun ImageEditorGestureAwareControl( + imageEditorController: ImageController?, + modifier: Modifier = Modifier, + extraCheck: () -> Boolean = { true }, + content: @Composable () -> Unit +) { + val isGestureActive = imageEditorController?.imageEditorState?.isGestureActive ?: false + + AnimatedVisibility( + modifier = modifier, + enter = MediaSendMetrics.ControlEnterTransition, + exit = MediaSendMetrics.ControlExitTransition, + visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && !isGestureActive && extraCheck() + ) { + content() + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt index 47eadc937b..21353201fa 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt @@ -45,6 +45,10 @@ class ImageEditorState( var redoAvailable: Boolean by mutableStateOf(false) private set + /** True while the user is actively manipulating the image: drawing, moving, scaling, or rotating. */ + var isGestureActive: Boolean by mutableStateOf(false) + internal set + var textEditingElement: EditorElement? = null var isDrawing: Boolean = false var isBlur: Boolean = false diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt new file mode 100644 index 0000000000..0fd2d62d2b --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.R +import org.signal.mediasend.edit.ImageController + +@Composable +internal fun ImageEditorUndoRedoButtons( + imageEditorController: ImageController?, + modifier: Modifier = Modifier, + canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false, + canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false +) { + ImageEditorGestureAwareControl(imageEditorController, modifier = modifier, extraCheck = { + canUndo || canRedo + }) { + Row(horizontalArrangement = spacedBy(8.dp)) { + IconButton( + enabled = canUndo, + onClick = { + imageEditorController?.undo() + }, + colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5) + ) { + Icon( + imageVector = SignalIcons.Undo.imageVector, + contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__undo_edit) + ) + } + + IconButton( + enabled = canRedo, + onClick = { + imageEditorController?.redo() + }, + colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5) + ) { + Icon( + imageVector = SignalIcons.Redo.imageVector, + contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__redo_edit) + ) + } + } + } +} + +@DayNightPreviews +@Composable +private fun ImageEditorUndoRedoButtonsPreview() { + Previews.Preview { + ImageEditorUndoRedoButtons( + imageEditorController = remember { + ImageController(EditorModel.create(0x0)).apply { + enterDrawMode() + } + }, + canUndo = true, + canRedo = true + ) + } +} diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index 8a834bb72e..b8e9d3eee7 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -51,7 +51,16 @@ Brush + + Clear all + + + Undo edit + + Redo edit + + Play video