mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Merge pull request #6949 from element-hq/feature/bma/flipImage
Add flip actions to image edition
This commit is contained in:
+2
@@ -17,6 +17,8 @@ sealed interface AttachmentsPreviewEvent {
|
||||
data object OpenImageEditor : AttachmentsPreviewEvent
|
||||
data object CloseImageEditor : AttachmentsPreviewEvent
|
||||
data object RotateImageToTheLeft : AttachmentsPreviewEvent
|
||||
data object FlipImageHorizontally : AttachmentsPreviewEvent
|
||||
data object FlipImageVertically : AttachmentsPreviewEvent
|
||||
data object ApplyImageEdits : AttachmentsPreviewEvent
|
||||
data object ResetImageEdits : AttachmentsPreviewEvent
|
||||
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
|
||||
|
||||
+12
@@ -295,6 +295,18 @@ class AttachmentsPreviewPresenter(
|
||||
edits = pendingState.edits.rotateAntiClockwise()
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvent.FlipImageHorizontally -> {
|
||||
val pendingState = imageEditorState ?: return
|
||||
imageEditorState = pendingState.copy(
|
||||
edits = pendingState.edits.flipHorizontally()
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvent.FlipImageVertically -> {
|
||||
val pendingState = imageEditorState ?: return
|
||||
imageEditorState = pendingState.copy(
|
||||
edits = pendingState.edits.flipVertically()
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvent.ResetImageEdits -> {
|
||||
imageEditorState = imageEditorState?.copy(
|
||||
edits = AttachmentImageEdits()
|
||||
|
||||
+2
@@ -136,6 +136,8 @@ fun AttachmentsPreviewView(
|
||||
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
|
||||
},
|
||||
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
|
||||
onFlipHorizontallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) },
|
||||
onFlipVerticallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageVertically) },
|
||||
onCancelClick = ::postCloseImageEditor,
|
||||
onResetClick = ::postResetImageEditor,
|
||||
onDoneClick = ::postApplyImageEdits,
|
||||
|
||||
+24
-13
@@ -87,30 +87,30 @@ class DefaultAttachmentImageEditor(
|
||||
decodedBitmap.recycle()
|
||||
}
|
||||
|
||||
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns)
|
||||
if (rotatedBitmap !== normalizedBitmap) {
|
||||
val transformedBitmap = normalizedBitmap.applyEdits(edits)
|
||||
if (transformedBitmap !== normalizedBitmap) {
|
||||
normalizedBitmap.recycle()
|
||||
}
|
||||
|
||||
val cropRect = edits.cropRect.toPixelRect(
|
||||
imageWidth = rotatedBitmap.width,
|
||||
imageHeight = rotatedBitmap.height,
|
||||
imageWidth = transformedBitmap.width,
|
||||
imageHeight = transformedBitmap.height,
|
||||
)
|
||||
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
|
||||
cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height
|
||||
cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height
|
||||
val croppedBitmap = if (isCropUnchanged) {
|
||||
rotatedBitmap
|
||||
transformedBitmap
|
||||
} else {
|
||||
Bitmap.createBitmap(
|
||||
rotatedBitmap,
|
||||
transformedBitmap,
|
||||
cropRect.left,
|
||||
cropRect.top,
|
||||
cropRect.width(),
|
||||
cropRect.height(),
|
||||
)
|
||||
}
|
||||
if (croppedBitmap !== rotatedBitmap) {
|
||||
rotatedBitmap.recycle()
|
||||
if (croppedBitmap !== transformedBitmap) {
|
||||
transformedBitmap.recycle()
|
||||
}
|
||||
|
||||
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
|
||||
@@ -141,11 +141,22 @@ internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap {
|
||||
val normalizedTurns = (quarterTurns % 4 + 4) % 4
|
||||
if (normalizedTurns == 0) return this
|
||||
private fun Bitmap.applyEdits(edits: AttachmentImageEdits): Bitmap {
|
||||
val normalizedTurns = (edits.rotationQuarterTurns % 4 + 4) % 4
|
||||
if (normalizedTurns == 0 && !edits.isFlippedHorizontally && !edits.isFlippedVertically) {
|
||||
return this
|
||||
}
|
||||
val centerX = width / 2f
|
||||
val centerY = height / 2f
|
||||
val matrix = Matrix().apply {
|
||||
postRotate(normalizedTurns * 90f)
|
||||
val scaleX = if (edits.isFlippedHorizontally) -1f else 1f
|
||||
val scaleY = if (edits.isFlippedVertically) -1f else 1f
|
||||
if (scaleX < 0f || scaleY < 0f) {
|
||||
postScale(scaleX, scaleY, centerX, centerY)
|
||||
}
|
||||
if (normalizedTurns != 0) {
|
||||
postRotate(normalizedTurns * 90f, centerX, centerY)
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
|
||||
}
|
||||
|
||||
+40
-7
@@ -26,6 +26,8 @@ data class AttachmentImageEditorState(
|
||||
data class AttachmentImageEdits(
|
||||
val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
|
||||
val rotationQuarterTurns: Int = 0,
|
||||
val isFlippedHorizontally: Boolean = false,
|
||||
val isFlippedVertically: Boolean = false,
|
||||
) {
|
||||
val normalizedRotationQuarterTurns: Int
|
||||
get() = rotationQuarterTurns % 4
|
||||
@@ -34,18 +36,32 @@ data class AttachmentImageEdits(
|
||||
get() = normalizedRotationQuarterTurns * 90
|
||||
|
||||
val hasChanges: Boolean
|
||||
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
|
||||
get() = cropRect != NormalizedCropRect.default() ||
|
||||
normalizedRotationQuarterTurns != 0 ||
|
||||
isFlippedHorizontally ||
|
||||
isFlippedVertically
|
||||
|
||||
fun rotateAntiClockwise(): AttachmentImageEdits {
|
||||
return copy(
|
||||
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
|
||||
// Also update the crop rect to keep the same selected area
|
||||
cropRect = NormalizedCropRect(
|
||||
left = cropRect.top,
|
||||
top = 1f - cropRect.right,
|
||||
right = cropRect.bottom,
|
||||
bottom = 1f - cropRect.left,
|
||||
)
|
||||
cropRect = cropRect.rotateAntiClockwise()
|
||||
)
|
||||
}
|
||||
|
||||
fun flipHorizontally(): AttachmentImageEdits {
|
||||
return copy(
|
||||
isFlippedHorizontally = !isFlippedHorizontally,
|
||||
// Also update the crop rect to keep the same selected area
|
||||
cropRect = cropRect.flipHorizontally(),
|
||||
)
|
||||
}
|
||||
|
||||
fun flipVertically(): AttachmentImageEdits {
|
||||
return copy(
|
||||
isFlippedVertically = !isFlippedVertically,
|
||||
// Also update the crop rect to keep the same selected area
|
||||
cropRect = cropRect.flipVertically(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +151,23 @@ data class NormalizedCropRect(
|
||||
)
|
||||
}
|
||||
|
||||
fun rotateAntiClockwise() = copy(
|
||||
left = top,
|
||||
top = 1f - right,
|
||||
right = bottom,
|
||||
bottom = 1f - left,
|
||||
)
|
||||
|
||||
fun flipHorizontally() = copy(
|
||||
left = 1f - right,
|
||||
right = 1f - left,
|
||||
)
|
||||
|
||||
fun flipVertically() = copy(
|
||||
top = 1f - bottom,
|
||||
bottom = 1f - top,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun default() = NormalizedCropRect(
|
||||
left = DEFAULT_CROP_MARGIN,
|
||||
|
||||
+10
@@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
|
||||
),
|
||||
previewDebug = true,
|
||||
),
|
||||
anAttachmentImageEditorState(
|
||||
edits = AttachmentImageEdits(
|
||||
cropRect = caterpillarCrop,
|
||||
).flipHorizontally(),
|
||||
),
|
||||
anAttachmentImageEditorState(
|
||||
edits = AttachmentImageEdits(
|
||||
cropRect = caterpillarCrop,
|
||||
).flipVertically(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+57
-15
@@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -90,6 +88,8 @@ fun AttachmentImageEditorView(
|
||||
state: AttachmentImageEditorState,
|
||||
onCropRectChange: (NormalizedCropRect) -> Unit,
|
||||
onRotateClick: () -> Unit,
|
||||
onFlipHorizontallyClick: () -> Unit,
|
||||
onFlipVerticallyClick: () -> Unit,
|
||||
onResetClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onDoneClick: () -> Unit,
|
||||
@@ -101,8 +101,18 @@ fun AttachmentImageEditorView(
|
||||
state.edits.rotationDegrees,
|
||||
state.edits.rotationDegrees,
|
||||
)
|
||||
val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault
|
||||
|
||||
val flipHorizontalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally)
|
||||
val flipHorizontalState = if (state.edits.isFlippedHorizontally) {
|
||||
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_flipped)
|
||||
} else {
|
||||
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_original)
|
||||
}
|
||||
val flipVerticalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_vertically)
|
||||
val flipVerticalState = if (state.edits.isFlippedVertically) {
|
||||
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_flipped)
|
||||
} else {
|
||||
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_original)
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@@ -158,37 +168,57 @@ fun AttachmentImageEditorView(
|
||||
onClick = onResetClick,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
Row(
|
||||
modifier = Modifier.weight(2f),
|
||||
// Center the content horizontally
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onFlipHorizontallyClick,
|
||||
modifier = Modifier
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = flipHorizontalLabel
|
||||
stateDescription = flipHorizontalState
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.FlipHorizontal(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onRotateClick,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = rotateButtonBackground,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = rotateContentDescription
|
||||
stateDescription = rotationStateDescription
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(22.dp),
|
||||
imageVector = CompoundIcons.RotateLeft(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onFlipVerticallyClick,
|
||||
modifier = Modifier
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = flipVerticalLabel
|
||||
stateDescription = flipVerticalState
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.FlipVertical(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_done),
|
||||
text = stringResource(CommonStrings.action_save),
|
||||
onClick = onDoneClick,
|
||||
)
|
||||
}
|
||||
@@ -204,6 +234,8 @@ private fun BoxScope.CropEditorCanvas(
|
||||
) {
|
||||
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
|
||||
val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns
|
||||
val flipScaleX = if (state.edits.isFlippedHorizontally) -1f else 1f
|
||||
val flipScaleY = if (state.edits.isFlippedVertically) -1f else 1f
|
||||
|
||||
var imageRect by remember { mutableStateOf(Rect.Zero) }
|
||||
|
||||
@@ -257,6 +289,10 @@ private fun BoxScope.CropEditorCanvas(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
|
||||
.graphicsLayer {
|
||||
scaleX = flipScaleX
|
||||
scaleY = flipScaleY
|
||||
}
|
||||
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
@@ -266,6 +302,10 @@ private fun BoxScope.CropEditorCanvas(
|
||||
contentDescription = stringResource(CommonStrings.common_image),
|
||||
modifier = Modifier
|
||||
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
|
||||
.graphicsLayer {
|
||||
scaleX = flipScaleX
|
||||
scaleY = flipScaleY
|
||||
}
|
||||
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
|
||||
contentScale = ContentScale.Fit,
|
||||
onState = { painterState ->
|
||||
@@ -642,6 +682,8 @@ internal fun AttachmentImageEditorViewPreview(
|
||||
state = state,
|
||||
onCropRectChange = {},
|
||||
onRotateClick = {},
|
||||
onFlipHorizontallyClick = {},
|
||||
onFlipVerticallyClick = {},
|
||||
onResetClick = {},
|
||||
onCancelClick = {},
|
||||
onDoneClick = {},
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||
<string name="emoji_picker_category_recent">"Recent emojis"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically">"Flip image vertically"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Flipped vertically"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="one">"%1$d degree"</item>
|
||||
|
||||
+33
-2
@@ -624,7 +624,11 @@ class AttachmentsPreviewPresenterTest {
|
||||
val croppedState = awaitItem()
|
||||
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
|
||||
val rotatedState = awaitItem()
|
||||
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||
rotatedState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
|
||||
val flippedHorizontallyState = awaitItem()
|
||||
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
|
||||
val flippedState = awaitItem()
|
||||
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
|
||||
|
||||
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
|
||||
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
|
||||
@@ -638,9 +642,36 @@ class AttachmentsPreviewPresenterTest {
|
||||
right = cropRect.bottom,
|
||||
bottom = 1f - cropRect.left,
|
||||
)
|
||||
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect)
|
||||
val flippedCropRect = NormalizedCropRect(
|
||||
left = 1f - rotatedCropRect.right,
|
||||
top = 1f - rotatedCropRect.bottom,
|
||||
right = 1f - rotatedCropRect.left,
|
||||
bottom = 1f - rotatedCropRect.top,
|
||||
)
|
||||
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(flippedCropRect)
|
||||
assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3)
|
||||
assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270)
|
||||
assertThat(reopenedState.imageEditorState.edits.isFlippedHorizontally).isTrue()
|
||||
assertThat(reopenedState.imageEditorState.edits.isFlippedVertically).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - image editor flip events update edits`() = runTest {
|
||||
val presenter = createAttachmentsPreviewPresenter(displayMediaQualitySelectorViews = true)
|
||||
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
|
||||
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
|
||||
|
||||
editorState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
|
||||
val flippedHorizontallyState = awaitItem()
|
||||
assertThat(flippedHorizontallyState.imageEditorState?.edits?.isFlippedHorizontally).isTrue()
|
||||
|
||||
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
|
||||
val flippedState = awaitItem()
|
||||
assertThat(flippedState.imageEditorState?.edits?.isFlippedVertically).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -42,4 +42,37 @@ class AttachmentImageEditsTest {
|
||||
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f)
|
||||
assertThat(result.hasChanges).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flip horizontally updates crop and change tracking`() {
|
||||
val sut = AttachmentImageEdits(
|
||||
cropRect = NormalizedCropRect(
|
||||
left = 0.1f,
|
||||
top = 0.3f,
|
||||
right = 0.6f,
|
||||
bottom = 0.9f,
|
||||
)
|
||||
)
|
||||
val result = sut.flipHorizontally()
|
||||
assertThat(result.isFlippedHorizontally).isTrue()
|
||||
assertThat(result.cropRect.left).isWithin(0.0001f).of(0.4f)
|
||||
assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f)
|
||||
assertThat(result.cropRect.top).isWithin(0.0001f).of(0.3f)
|
||||
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.9f)
|
||||
assertThat(result.hasChanges).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flip vertical twice resets to default state`() {
|
||||
val edits = AttachmentImageEdits().flipVertically().flipVertically()
|
||||
assertThat(edits.isFlippedVertically).isFalse()
|
||||
assertThat(edits.hasChanges).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flip horizontally twice resets to default state`() {
|
||||
val edits = AttachmentImageEdits().flipHorizontally().flipHorizontally()
|
||||
assertThat(edits.isFlippedVertically).isFalse()
|
||||
assertThat(edits.hasChanges).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user