Add shared media toolbar to new media send flow.

This commit is contained in:
Alex Hart
2026-07-30 16:34:43 -03:00
parent 17225d3981
commit 36fb8d8aac
4 changed files with 193 additions and 108 deletions
@@ -69,8 +69,8 @@ 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
import org.signal.mediasend.edit.video.VideoEditorViewModel
import org.signal.mediasend.edit.video.VideoTrimBar
import org.signal.mediasend.rememberPreviewState
@Composable
@@ -284,10 +284,17 @@ fun MediaEditScreen(
if (controller.isUserDrawing) {
DrawModeColorBar(imageEditorController = controller)
}
}
if (isSmallWindowBreakpoint) {
ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent)
}
if (isSmallWindowBreakpoint) {
MediaToolbar(
focusedUri = focusedUri,
focusedEditorState = focusedEditorState,
state = state,
onEvent = onEvent,
imageController = imageController,
isTextEditing = isTextEditing
)
}
}
@@ -311,16 +318,16 @@ fun MediaEditScreen(
}
}
if (!isSmallWindowBreakpoint && imageController != null) {
ImageEditorToolbar(
imageEditorController = imageController,
if (!isSmallWindowBreakpoint) {
MediaToolbar(
focusedUri = focusedUri,
focusedEditorState = focusedEditorState,
state = state,
onEvent = onEvent,
imageController = imageController,
isTextEditing = isTextEditing,
modifier = Modifier
.align(Alignment.CenterEnd)
.navigationBarsPadding()
.padding(end = 24.dp)
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
)
}
@@ -359,6 +366,49 @@ fun MediaEditScreen(
}
}
/**
* Toolbar allowing for common actions
*/
@Composable
private fun MediaToolbar(
state: MediaSendState,
onEvent: (MediaEditScreenEvent) -> Unit,
focusedUri: Uri?,
focusedEditorState: EditorState?,
imageController: ImageController?,
isTextEditing: Boolean,
modifier: Modifier = Modifier
) {
if (focusedUri == null) {
return
}
when (focusedEditorState) {
null -> return
is EditorState.Image -> {
imageController?.let {
ImageEditorToolbar(
imageEditorController = it,
state = state,
onEvent = onEvent,
modifier = modifier
.navigationBarsPadding()
.padding(end = 24.dp)
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
)
}
}
else -> MediaEditorToolbar(modifier = modifier) {
MediaEditorToolbarSharedButtons(
state = state,
onEvent = onEvent,
canSave = focusedEditorState is EditorState.VideoTrim
)
}
}
}
/**
* A control in the bottom stack, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout
* space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture
@@ -404,7 +454,7 @@ private fun VideoTrimTimeline(
}
}
VideoEditorToolbar(
VideoTrimBar(
videoUri = videoUri,
mediaInputFactory = MediaSendDependencies.mediaInputFactory,
videoTrimData = editorState.videoTrimData,
@@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
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.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.IconButtons
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.copied.androidx.compose.material3.IconButtonColors
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.SentMediaQuality
@Composable
internal fun MediaEditorToolbar(
modifier: Modifier = Modifier,
leading: @Composable () -> Unit = {},
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
val windowBreakpoint = rememberWindowBreakpoint()
val isRow = windowBreakpoint is 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()
}
}
}
@Composable
internal fun MediaEditorToolbarButton(
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
internal fun MediaEditorToolbarSharedButtons(
state: MediaSendState,
canSave: Boolean,
onEvent: (MediaEditScreenEvent) -> Unit
) {
MediaEditorToolbarButton(
imageVector = if (state.sentMediaQuality == SentMediaQuality.HIGH) {
SignalIcons.QualityHigh.imageVector
} else {
SignalIcons.QualityHighSlash.imageVector
},
onClick = { onEvent(MediaEditScreenEvent.ToggleMediaQuality) }
)
if (canSave) {
MediaEditorToolbarButton(
imageVector = SignalIcons.Save.imageVector,
onClick = { onEvent(MediaEditScreenEvent.SaveMedia) }
)
}
MediaEditorToolbarButton(
imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset
onClick = { onEvent(MediaEditScreenEvent.NavigateToGallery) }
)
}
@@ -6,15 +6,7 @@
package org.signal.mediasend.edit.image
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
@@ -22,7 +14,6 @@ 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
@@ -31,16 +22,16 @@ 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 org.signal.mediasend.MediaSendState
import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.edit.ImageController
import org.signal.mediasend.edit.MediaEditScreenDialogs
import org.signal.mediasend.edit.MediaEditScreenEvent
import org.signal.mediasend.edit.MediaEditorToolbar
import org.signal.mediasend.edit.MediaEditorToolbarButton
import org.signal.mediasend.edit.MediaEditorToolbarSharedButtons
import org.signal.mediasend.rememberPreviewState
import java.util.EnumMap
@@ -81,34 +72,21 @@ private fun ImageEditorNoneStateToolbar(
onEvent: (MediaEditScreenEvent) -> Unit,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(modifier) {
ImageEditorButton(
MediaEditorToolbar(modifier) {
MediaEditorToolbarButton(
imageVector = SignalIcons.BrushPen.imageVector,
onClick = imageEditorController::beginDrawEdit
)
ImageEditorButton(
MediaEditorToolbarButton(
imageVector = SignalIcons.CropRotate.imageVector,
onClick = imageEditorController::beginCropAndRotateEdit
)
ImageEditorButton(
imageVector = if (state.sentMediaQuality == SentMediaQuality.HIGH) {
SignalIcons.QualityHigh.imageVector
} else {
SignalIcons.QualityHighSlash.imageVector
},
onClick = { onEvent(MediaEditScreenEvent.ToggleMediaQuality) }
)
ImageEditorButton(
imageVector = SignalIcons.Save.imageVector,
onClick = { onEvent(MediaEditScreenEvent.SaveMedia) }
)
ImageEditorButton(
imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset
onClick = { onEvent(MediaEditScreenEvent.NavigateToGallery) }
MediaEditorToolbarSharedButtons(
state = state,
canSave = true,
onEvent = onEvent
)
}
}
@@ -118,7 +96,7 @@ private fun ImageEditorDrawStateToolbar(
imageEditorController: ImageController,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(
MediaEditorToolbar(
modifier = modifier,
leading = {
CommitButton(imageEditorController)
@@ -174,7 +152,7 @@ private fun ImageEditorCropAndResizeToolbar(
imageEditorController: ImageController,
modifier: Modifier = Modifier
) {
OrientedImageEditorToolbar(
MediaEditorToolbar(
modifier = modifier,
leading = {
CommitButton(imageEditorController)
@@ -183,12 +161,12 @@ private fun ImageEditorCropAndResizeToolbar(
DiscardButton(imageEditorController)
}
) {
ImageEditorButton(
MediaEditorToolbarButton(
imageVector = SignalIcons.CropRotate.imageVector,
onClick = imageEditorController::rotate
)
ImageEditorButton(
MediaEditorToolbarButton(
imageVector = SignalIcons.Flip.imageVector,
onClick = imageEditorController::flip
)
@@ -218,7 +196,7 @@ private fun ImageEditorCropAndResizeToolbar(
@Composable
private fun CommitButton(imageEditorController: ImageController) {
ImageEditorButton(
MediaEditorToolbarButton(
imageVector = SignalIcons.Check.imageVector,
onClick = imageEditorController::commitEdit,
colors = IconButtons.iconButtonColors(
@@ -236,7 +214,7 @@ private fun DiscardButton(imageEditorController: ImageController) {
)
}
ImageEditorButton(
MediaEditorToolbarButton(
imageVector = SignalIcons.X.imageVector,
onClick = imageEditorController::requestCancelEdit,
colors = IconButtons.iconButtonColors(
@@ -264,21 +242,6 @@ private inline fun <reified E : Enum<E>> IconCrossfadeToggleButton(
}
}
@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,
@@ -298,47 +261,6 @@ private fun ImageEditorToggleButton(
}
}
@Composable
private fun OrientedImageEditorToolbar(
modifier: Modifier = Modifier,
leading: @Composable () -> Unit = {},
trailing: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
val windowBreakpoint = rememberWindowBreakpoint()
val isRow = windowBreakpoint is 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
@@ -44,7 +44,7 @@ private const val TAG = "VideoEditorToolbar"
* caps how long a range the user can trim to (0 means no cap).
*/
@Composable
fun VideoEditorToolbar(
fun VideoTrimBar(
videoUri: Uri,
mediaInputFactory: MediaInputFactory,
videoTrimData: VideoTrimData,
@@ -140,9 +140,9 @@ fun VideoEditorToolbar(
@Preview
@Composable
fun VideoEditorToolbarPreview() {
fun VideoTrimBarPreview() {
Previews.Preview {
VideoEditorToolbar(
VideoTrimBar(
videoUri = Uri.EMPTY,
mediaInputFactory = object : MediaInputFactory {
override fun createForUri(context: Context, uri: Uri) = throw UnsupportedOperationException()