mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-02 06:33:38 +01:00
Add large-screen media send toolbars for image editing.
This commit is contained in:
committed by
Cody Henthorne
parent
b4bfb67a44
commit
773d6c36dc
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user