Add large-screen media send toolbars for image editing.

This commit is contained in:
Alex Hart
2026-04-10 14:35:43 -03:00
committed by Cody Henthorne
parent b4bfb67a44
commit 773d6c36dc
41 changed files with 674 additions and 117 deletions

View File

@@ -6,8 +6,11 @@
package org.signal.mediasend
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.WriteWith
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
@@ -108,22 +111,38 @@ data class MediaSendState(
/**
* The [MediaFolder] list available on the system
*/
val mediaFolders: List<MediaFolder> = emptyList(),
val mediaFolders: @WriteWith<TransientMediaFolderListParceler> List<MediaFolder> = emptyList(),
/**
* The selected [MediaFolder] for which to display content in the Select screen
*/
val selectedMediaFolder: MediaFolder? = null,
val selectedMediaFolder: @WriteWith<TransientMediaFolderParceler> MediaFolder? = null,
/**
* The media content for a given selected [MediaFolder]
*/
val selectedMediaFolderItems: List<Media> = emptyList()
val selectedMediaFolderItems: @WriteWith<TransientMediaListParceler> List<Media> = emptyList()
) : Parcelable {
/**
* View-once toggle state.
* No-op parcelers for fields that are re-loaded on init and should not
* contribute to the saved-state bundle size.
*/
private object TransientMediaFolderListParceler : Parceler<List<MediaFolder>> {
override fun create(parcel: Parcel): List<MediaFolder> = emptyList()
override fun List<MediaFolder>.write(parcel: Parcel, flags: Int) = Unit
}
private object TransientMediaFolderParceler : Parceler<MediaFolder?> {
override fun create(parcel: Parcel): MediaFolder? = null
override fun MediaFolder?.write(parcel: Parcel, flags: Int) = Unit
}
private object TransientMediaListParceler : Parceler<List<Media>> {
override fun create(parcel: Parcel): List<Media> = emptyList()
override fun List<Media>.write(parcel: Parcel, flags: Int) = Unit
}
enum class ViewOnceToggleState(val code: Int) {
OFF(0),
ONCE(1);

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import org.signal.imageeditor.core.model.EditorModel
@Stable
sealed interface EditorController {
@Stable
class Container @RememberInComposition constructor() {
val controllers = SnapshotStateMap<Uri, EditorController>()
fun getOrCreateImageController(uri: Uri, editorModel: EditorModel): Image {
return controllers.getOrPut(uri) { Image(editorModel) } as Image
}
}
@Stable
class Image @RememberInComposition constructor(val editorModel: EditorModel) : EditorController {
override val isUserInEdit: Boolean
get() = mode != Mode.NONE
var mode: Mode by mutableStateOf(Mode.NONE)
var isCropLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
private set
val isUserDrawing: Boolean
get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT
val isUserBlurring: Boolean
get() = mode == Mode.BLUR
val isUserEnteringText: Boolean
get() = mode == Mode.TEXT
val isUserInsertingSticker: Boolean
get() = mode == Mode.INSERT_STICKER
fun beginDrawEdit() {
enterDrawMode()
}
fun beginCropAndRotateEdit() {
enterCropMode()
}
fun cancelEdit() {
mode = Mode.NONE
}
fun commitEdit() {
mode = Mode.NONE
}
fun enterDrawMode() {
mode = Mode.DRAW
}
fun enterHighlightMode() {
mode = Mode.HIGHLIGHT
}
fun enterBlurMode() {
mode = Mode.BLUR
}
fun enterCropMode() {
mode = Mode.CROP
}
fun enterTextMode() {
mode = Mode.TEXT
}
fun enterStickerMode() {
mode = Mode.INSERT_STICKER
}
fun lockCrop() {
editorModel.setCropAspectLock(true)
isCropLocked = true
}
fun unlockCrop() {
editorModel.setCropAspectLock(false)
isCropLocked = false
}
fun flip() {
editorModel.flipHorizontal()
}
fun rotate() {
editorModel.rotate90anticlockwise()
}
fun toggleImageQuality() {
// TODO
}
fun saveToDisk() {
// TODO
}
fun addMedia() {
// TODO
}
enum class Mode {
NONE,
CROP,
TEXT,
DRAW,
HIGHLIGHT,
BLUR,
MOVE_STICKER,
MOVE_TEXT,
DELETE,
INSERT_STICKER
}
}
object VideoTrim : EditorController {
override val isUserInEdit: Boolean = false
}
val isUserInEdit: Boolean
}

View File

@@ -13,7 +13,7 @@ import org.signal.imageeditor.core.ImageEditorView
@Composable
fun ImageEditor(
controller: ImageEditorController,
controller: EditorController.Image,
modifier: Modifier = Modifier
) {
AndroidView(
@@ -27,17 +27,17 @@ fun ImageEditor(
)
}
private fun mapMode(mode: ImageEditorController.Mode): ImageEditorView.Mode {
private fun mapMode(mode: EditorController.Image.Mode): ImageEditorView.Mode {
return when (mode) {
ImageEditorController.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.DRAW -> ImageEditorView.Mode.Draw
ImageEditorController.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
ImageEditorController.Mode.BLUR -> ImageEditorView.Mode.Blur
ImageEditorController.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
ImageEditorController.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.DRAW -> ImageEditorView.Mode.Draw
EditorController.Image.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
EditorController.Image.Mode.BLUR -> ImageEditorView.Mode.Blur
EditorController.Image.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
EditorController.Image.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
}
}

View File

@@ -1,34 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.signal.imageeditor.core.model.EditorModel
@Stable
class ImageEditorController @RememberInComposition constructor(
val editorModel: EditorModel
) {
var mode: Mode by mutableStateOf(Mode.NONE)
enum class Mode {
NONE,
CROP,
TEXT,
DRAW,
HIGHLIGHT,
BLUR,
MOVE_STICKER,
MOVE_TEXT,
DELETE,
INSERT_STICKER
}
}

View File

@@ -0,0 +1,365 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.FoldablePortraitDayPreview
import org.signal.core.ui.compose.FoldablePortraitNightPreview
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.IconButtons.iconToggleButtonColors
import org.signal.core.ui.compose.PhonePortraitDayPreview
import org.signal.core.ui.compose.PhonePortraitNightPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.copied.androidx.compose.material3.IconButtonColors
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.core.util.next
import org.signal.imageeditor.core.model.EditorModel
import java.util.EnumMap
@Composable
fun ImageEditorToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
when (imageEditorController.mode) {
EditorController.Image.Mode.NONE -> {
ImageEditorNoneStateToolbar(imageEditorController, modifier)
}
EditorController.Image.Mode.CROP -> {
ImageEditorCropAndResizeToolbar(imageEditorController, modifier)
}
else -> {
ImageEditorDrawStateToolbar(imageEditorController, modifier)
}
}
}
/**
* Allows user to perform actions while viewing an editable image.
*/
@Composable
private fun ImageEditorNoneStateToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(modifier) {
ImageEditorButton(
imageVector = SignalIcons.BrushPen.imageVector,
onClick = imageEditorController::beginDrawEdit
)
ImageEditorButton(
imageVector = SignalIcons.CropRotate.imageVector,
onClick = imageEditorController::beginCropAndRotateEdit
)
ImageEditorButton(
imageVector = SignalIcons.QualityHigh.imageVector,
onClick = imageEditorController::toggleImageQuality
)
ImageEditorButton(
imageVector = SignalIcons.Save.imageVector,
onClick = imageEditorController::saveToDisk
)
ImageEditorButton(
imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset
onClick = imageEditorController::addMedia
)
}
}
@Composable
private fun ImageEditorDrawStateToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(
modifier = modifier,
leading = {
CommitButton(imageEditorController)
},
trailing = {
DiscardButton(imageEditorController)
}
) {
ImageEditorToggleButton(
imageVector = SignalIcons.Draw.imageVector,
checked = imageEditorController.isUserDrawing,
onCheckChanged = {
if (!imageEditorController.isUserDrawing) {
imageEditorController.enterDrawMode()
}
}
)
ImageEditorToggleButton(
imageVector = SignalIcons.Text.imageVector,
checked = imageEditorController.isUserEnteringText,
onCheckChanged = {
if (!imageEditorController.isUserEnteringText) {
imageEditorController.enterTextMode()
}
}
)
ImageEditorToggleButton(
imageVector = SignalIcons.Sticker.imageVector,
checked = imageEditorController.isUserInsertingSticker,
onCheckChanged = {
if (!imageEditorController.isUserInsertingSticker) {
imageEditorController.enterStickerMode()
}
}
)
ImageEditorToggleButton(
imageVector = SignalIcons.Blur.imageVector,
checked = imageEditorController.isUserBlurring,
onCheckChanged = {
if (!imageEditorController.isUserBlurring) {
imageEditorController.enterBlurMode()
}
}
)
}
}
@Composable
private fun ImageEditorCropAndResizeToolbar(
imageEditorController: EditorController.Image,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(
modifier = modifier,
leading = {
CommitButton(imageEditorController)
},
trailing = {
DiscardButton(imageEditorController)
}
) {
ImageEditorButton(
imageVector = SignalIcons.CropRotate.imageVector,
onClick = imageEditorController::rotate
)
ImageEditorButton(
imageVector = SignalIcons.Flip.imageVector,
onClick = imageEditorController::flip
)
val cropLockImageVector = SignalIcons.CropLock.imageVector
val cropUnlockImageVector = SignalIcons.CropUnlock.imageVector
IconCrossfadeToggleButton(
target = if (imageEditorController.isCropLocked) CropLock.LOCKED else CropLock.UNLOCKED,
setTarget = { target ->
when (target) {
CropLock.LOCKED -> imageEditorController.lockCrop()
CropLock.UNLOCKED -> imageEditorController.unlockCrop()
}
},
targetToImageMap = remember(cropLockImageVector, cropUnlockImageVector) {
EnumMap<CropLock, ImageVector>(
CropLock::class.java
).apply {
put(CropLock.LOCKED, cropLockImageVector)
put(CropLock.UNLOCKED, cropUnlockImageVector)
}
}
)
}
}
@Composable
private fun CommitButton(imageEditorController: EditorController.Image) {
ImageEditorButton(
imageVector = SignalIcons.Check.imageVector,
onClick = imageEditorController::commitEdit,
colors = IconButtons.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
@Composable
private fun DiscardButton(imageEditorController: EditorController.Image) {
ImageEditorButton(
imageVector = SignalIcons.X.imageVector,
onClick = imageEditorController::cancelEdit,
colors = IconButtons.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
@Composable
private inline fun <reified E : Enum<E>> IconCrossfadeToggleButton(
target: E,
crossinline setTarget: (E) -> Unit,
targetToImageMap: EnumMap<E, ImageVector>
) {
IconButtons.IconButton(
onClick = { setTarget(target.next()) }
) {
Crossfade(target) { enumValue ->
Icon(
imageVector = targetToImageMap[enumValue]!!,
contentDescription = null, // TODO
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
private fun ImageEditorButton(
imageVector: ImageVector,
onClick: () -> Unit,
contentDescription: String? = null,
colors: IconButtonColors = IconButtons.iconButtonColors()
) {
IconButtons.IconButton(
onClick = onClick,
colors = colors
) {
Icon(imageVector = imageVector, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@Composable
private fun ImageEditorToggleButton(
imageVector: ImageVector,
checked: Boolean,
onCheckChanged: (Boolean) -> Unit,
contentDescription: String? = null
) {
IconButtons.IconToggleButton(
checked = checked,
onCheckedChange = onCheckChanged,
colors = iconToggleButtonColors(
checkedContentColor = MaterialTheme.colorScheme.onSurface,
checkedContainerColor = SignalTheme.colors.colorTransparentInverse2
)
) {
Icon(imageVector = imageVector, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@Composable
private fun OrientedImageEditorToolbar(
modifier: Modifier = Modifier,
leading: @Composable () -> Unit = {},
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
val windowBreakpoint = rememberWindowBreakpoint()
val isRow = windowBreakpoint == WindowBreakpoint.SMALL
if (isRow) {
Row(modifier = modifier.height(48.dp)) {
leading()
Row(
modifier = Modifier
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
) {
content()
}
trailing()
}
} else {
Column(modifier = modifier.width(48.dp)) {
leading()
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
) {
content()
}
trailing()
}
}
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@FoldablePortraitDayPreview
@FoldablePortraitNightPreview
@Composable
private fun ImageEditorNoneStateToolbarPreview() {
Previews.Preview {
ImageEditorNoneStateToolbar(
imageEditorController = remember {
EditorController.Image(EditorModel.create(0))
}
)
}
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@FoldablePortraitDayPreview
@FoldablePortraitNightPreview
@Composable
private fun ImageEditorDrawStateToolbarPreview() {
Previews.Preview {
ImageEditorDrawStateToolbar(
imageEditorController = remember {
EditorController.Image(EditorModel.create(0)).apply {
enterDrawMode()
}
}
)
}
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@FoldablePortraitDayPreview
@FoldablePortraitNightPreview
@Composable
private fun ImageEditorCropAndResizeToolbarPreview() {
Previews.Preview {
ImageEditorCropAndResizeToolbar(
imageEditorController = remember {
EditorController.Image(EditorModel.create(0)).apply {
enterCropMode()
}
}
)
}
}
private enum class CropLock {
LOCKED, UNLOCKED
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.runtime.Composable
/**
* Allows user to perform actions while viewing an editable image.
*/
@Composable
fun ImageEditorTopLevelToolbar(
imageEditorController: ImageEditorController
) {
// Draw -- imageEditorController draw mode
// Crop&Rotate -- imageEditorController crop mode
// Quality -- callback toggle quality
// Save -- callback save to disk
// Add -- callback go to media select
}
interface ImageEditorToolbarsCallback {
object Empty : ImageEditorToolbarsCallback
}

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -28,12 +27,13 @@ import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.launch
import org.signal.core.models.media.Media
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.ContentTypeUtil
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.imageeditor.core.model.EditorModel
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendNavKey
import org.signal.mediasend.MediaSendState
@@ -46,7 +46,6 @@ fun MediaEditScreen(
backStack: NavBackStack<NavKey>,
videoEditorSlot: @Composable () -> Unit = {}
) {
val isFocusedMediaVideo = ContentTypeUtil.isVideoType(state.focusedMedia?.contentType)
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(
@@ -59,15 +58,28 @@ fun MediaEditScreen(
.fillMaxSize()
.navigationBarsPadding()
) {
val isSmallWindowBreakpoint = rememberWindowBreakpoint() == WindowBreakpoint.SMALL
val controllers = remember { EditorController.Container() }
val currentController = state.focusedMedia?.let {
when (val editorState = state.editorStateMap[it.uri]) {
is EditorState.Image -> controllers.getOrCreateImageController(it.uri, editorState.model)
is EditorState.VideoTrim -> EditorController.VideoTrim
null -> error("Invalid editor state.")
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
snapPosition = SnapPosition.Center
snapPosition = SnapPosition.Center,
userScrollEnabled = currentController?.isUserInEdit != true
) { index ->
when (val editorState = state.editorStateMap[state.selectedMedia[index].uri]) {
val uri = state.selectedMedia[index].uri
when (val editorState = state.editorStateMap[uri]) {
is EditorState.Image -> {
ImageEditor(
controller = remember { ImageEditorController(editorState.model) },
controller = controllers.getOrCreateImageController(uri, editorState.model),
modifier = Modifier.fillMaxSize()
)
}
@@ -104,10 +116,13 @@ fun MediaEditScreen(
)
}
if (isFocusedMediaVideo) {
// Video editor hud
} else if (!currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) {
// Image editor HU
when (currentController) {
is EditorController.Image -> {
if (isSmallWindowBreakpoint) {
ImageEditorToolbar(imageEditorController = currentController)
}
}
is EditorController.VideoTrim, null -> Unit
}
AddAMessageRow(
@@ -120,6 +135,13 @@ fun MediaEditScreen(
.padding(bottom = 16.dp)
)
}
if (!isSmallWindowBreakpoint && currentController is EditorController.Image) {
ImageEditorToolbar(
imageEditorController = currentController,
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 24.dp)
)
}
}
}
@@ -132,7 +154,10 @@ private fun MediaEditScreenPreview() {
MediaEditScreen(
state = MediaSendState(
selectedMedia = selectedMedia,
focusedMedia = selectedMedia.first()
focusedMedia = selectedMedia.first(),
editorStateMap = mutableMapOf(
selectedMedia.first().uri to EditorState.Image(EditorModel.create(0))
)
),
callback = MediaEditScreenCallback.Empty,
backStack = rememberNavBackStack(MediaSendNavKey.Edit),