From bb2779549e6f94689f9e71be66b835d1616d4e7b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 21 May 2026 18:04:49 +0200 Subject: [PATCH] Update design and UX Update and add tests Improve preview --- .../preview/AttachmentsPreviewEvent.kt | 3 +- .../preview/AttachmentsPreviewPresenter.kt | 9 +- .../preview/AttachmentsPreviewView.kt | 86 +++-- .../imageeditor/AttachmentImageEditModels.kt | 85 +++-- .../imageeditor/AttachmentImageEditor.kt | 4 +- .../AttachmentImageEditorStateProvider.kt | 61 ++++ .../imageeditor/AttachmentImageEditorView.kt | 323 ++++++++++-------- .../impl/src/main/res/values/localazy.xml | 7 + .../impl/src/main/res/values/temporary.xml | 8 - .../AttachmentsPreviewPresenterTest.kt | 4 +- .../AttachmentImageEditModelsTest.kt | 78 ----- .../imageeditor/AttachmentImageEditsTest.kt | 45 +++ .../DefaultAttachmentImageEditorTest.kt | 24 ++ .../imageeditor/NormalizedCropRectTest.kt | 137 ++++++++ .../src/main/res/values/localazy.xml | 1 + tools/localazy/config.json | 1 + 16 files changed, 575 insertions(+), 301 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt delete mode 100644 features/messages/impl/src/main/res/values/temporary.xml delete mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt index 1957a8b0f7..a7c64845d1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt @@ -16,8 +16,9 @@ sealed interface AttachmentsPreviewEvent { data object CancelAndClearSendState : AttachmentsPreviewEvent data object OpenImageEditor : AttachmentsPreviewEvent data object CloseImageEditor : AttachmentsPreviewEvent - data object RotateImage : AttachmentsPreviewEvent + data object RotateImageToTheLeft : AttachmentsPreviewEvent data object ApplyImageEdits : AttachmentsPreviewEvent + data object ResetImageEdits : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent data object ClearImageEditError : AttachmentsPreviewEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index b5cb13e3e3..7e8bfbdc3f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -287,10 +287,15 @@ class AttachmentsPreviewPresenter( edits = pendingState.edits.copy(cropRect = event.cropRect) ) } - AttachmentsPreviewEvent.RotateImage -> { + AttachmentsPreviewEvent.RotateImageToTheLeft -> { val pendingState = imageEditorState ?: return imageEditorState = pendingState.copy( - edits = pendingState.edits.rotateClockwise() + edits = pendingState.edits.rotateAntiClockwise() + ) + } + AttachmentsPreviewEvent.ResetImageEdits -> { + imageEditorState = imageEditorState?.copy( + edits = AttachmentImageEdits() ) } AttachmentsPreviewEvent.ApplyImageEdits -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 2c51fa4499..1714a8b8dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -31,11 +31,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError @@ -56,12 +57,11 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Switch import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.mediaviewer.api.local.LocalMedia @@ -76,6 +76,9 @@ import io.element.android.wysiwyg.display.TextDisplay import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +/** + * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentsPreviewView( @@ -110,6 +113,10 @@ fun AttachmentsPreviewView( state.eventSink(AttachmentsPreviewEvent.CloseImageEditor) } + fun postResetImageEditor() { + state.eventSink(AttachmentsPreviewEvent.ResetImageEdits) + } + fun postApplyImageEdits() { state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) } @@ -128,8 +135,9 @@ fun AttachmentsPreviewView( onCropRectChange = { cropRect -> state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) }, - onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImage) }, + onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) }, onCancelClick = ::postCloseImageEditor, + onResetClick = ::postResetImageEditor, onDoneClick = ::postApplyImageEdits, modifier = modifier, ) @@ -140,19 +148,23 @@ fun AttachmentsPreviewView( TopAppBar( navigationIcon = { BackButton( - imageVector = CompoundIcons.Close(), onClick = ::postCancel, ) }, - title = {}, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(R.string.screen_media_upload_preview_title), + ) + }, actions = { if (state.canEditImage && canShowEditAction) { - IconButton(onClick = ::postOpenImageEditor) { - Icon( - imageVector = CompoundIcons.Edit(), - contentDescription = stringResource(CommonStrings.action_edit), - ) - } + TextButton( + stringResource(CommonStrings.action_edit), + onClick = ::postOpenImageEditor + ) } } ) @@ -202,32 +214,32 @@ private fun AttachmentSendStateView( ) } else -> when (sendActionState) { - is SendActionState.Sending.Processing -> { - if (sendActionState.displayProgress) { + is SendActionState.Sending.Processing -> { + if (sendActionState.displayProgress) { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(CommonStrings.common_preparing), + showCancelButton = true, + onDismissRequest = onDismissClick, + ) + } + } + is SendActionState.Sending.Uploading -> { ProgressDialog( type = ProgressDialogType.Indeterminate, - text = stringResource(CommonStrings.common_preparing), + text = stringResource(id = CommonStrings.common_sending), showCancelButton = true, onDismissRequest = onDismissClick, ) } - } - is SendActionState.Sending.Uploading -> { - ProgressDialog( - type = ProgressDialogType.Indeterminate, - text = stringResource(id = CommonStrings.common_sending), - showCancelButton = true, - onDismissRequest = onDismissClick, - ) - } - is SendActionState.Failure -> { - RetryDialog( - content = stringResource(sendAttachmentError(sendActionState.error)), - onDismiss = onDismissClick, - onRetry = onRetryClick - ) - } - else -> Unit + is SendActionState.Failure -> { + RetryDialog( + content = stringResource(sendAttachmentError(sendActionState.error)), + onDismiss = onDismissClick, + onRetry = onRetryClick + ) + } + else -> Unit } } } @@ -291,7 +303,8 @@ private fun AttachmentPreviewContent( private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { if (state.displayMediaSelectorViews == true) { Row( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .niceClickable { state.isImageOptimizationEnabled?.let { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value)) @@ -300,7 +313,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { .padding(horizontal = 16.dp, vertical = 16.dp) ) { Text( - modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), style = ElementTheme.typography.fontBodyLgRegular, ) @@ -326,7 +341,8 @@ private fun VideoPresetSelector( if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp) .niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) } ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt index 90872df4f2..3143f2462e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt @@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito import androidx.compose.runtime.Immutable import io.element.android.libraries.mediaviewer.api.local.LocalMedia -private const val DEFAULT_CROP_MARGIN = 0.1f +private const val DEFAULT_CROP_MARGIN = 0f private const val MIN_CROP_SIZE = 0.1f @Immutable @@ -25,7 +25,7 @@ data class AttachmentImageEdits( val rotationQuarterTurns: Int = 0, ) { val normalizedRotationQuarterTurns: Int - get() = (rotationQuarterTurns % 4 + 4) % 4 + get() = rotationQuarterTurns % 4 val rotationDegrees: Int get() = normalizedRotationQuarterTurns * 90 @@ -33,8 +33,17 @@ data class AttachmentImageEdits( val hasChanges: Boolean get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 - fun rotateClockwise(): AttachmentImageEdits { - return copy(rotationQuarterTurns = (normalizedRotationQuarterTurns + 1) % 4) + 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, + ) + ) } } @@ -60,7 +69,13 @@ data class NormalizedCropRect( val height: Float get() = bottom - top - fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect { + fun applyChange(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { + is CropDragTarget.Move -> translate(deltaX, deltaY) + is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY) + is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY) + } + + private fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect { val clampedLeft = (left + deltaX).coerceIn(0f, 1f - width) val clampedTop = (top + deltaY).coerceIn(0f, 1f - height) return copy( @@ -71,34 +86,36 @@ data class NormalizedCropRect( ) } - fun resize(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { - CropDragTarget.Move -> translate(deltaX, deltaY) - CropDragTarget.TopLeft -> copy( + private fun dragWithCorner(dragTarget: CropDragTarget.Corner, deltaX: Float, deltaY: Float) = when (dragTarget) { + CropDragTarget.Corner.TopLeft -> copy( left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), ) - CropDragTarget.Top -> copy( - top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), - ) - CropDragTarget.TopRight -> copy( + CropDragTarget.Corner.TopRight -> copy( right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), ) - CropDragTarget.Right -> copy( - right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), - ) - CropDragTarget.BottomRight -> copy( + CropDragTarget.Corner.BottomRight -> copy( right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), ) - CropDragTarget.Bottom -> copy( - bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), - ) - CropDragTarget.BottomLeft -> copy( + CropDragTarget.Corner.BottomLeft -> copy( left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), ) - CropDragTarget.Left -> copy( + } + + private fun dragWithEdge(dragTarget: CropDragTarget.Edge, deltaX: Float, deltaY: Float) = when (dragTarget) { + CropDragTarget.Edge.Top -> copy( + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.Edge.Right -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.Edge.Bottom -> copy( + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.Edge.Left -> copy( left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), ) } @@ -113,14 +130,20 @@ data class NormalizedCropRect( } } -enum class CropDragTarget { - Move, - TopLeft, - Top, - TopRight, - Right, - BottomRight, - Bottom, - BottomLeft, - Left, +sealed interface CropDragTarget { + data object Move : CropDragTarget + + sealed interface Corner : CropDragTarget { + data object TopLeft : Corner + data object TopRight : Corner + data object BottomRight : Corner + data object BottomLeft : Corner + } + + sealed interface Edge : CropDragTarget { + data object Top : Edge + data object Right : Edge + data object Bottom : Edge + data object Left : Edge + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt index f003c19084..cb66802184 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt @@ -174,12 +174,12 @@ private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): P } private fun compressFormat(mimeType: String) = when (mimeType) { - "image/png" -> Bitmap.CompressFormat.PNG + MimeTypes.Png -> Bitmap.CompressFormat.PNG else -> Bitmap.CompressFormat.JPEG } private fun compressFileExtension(mimeType: String) = when (mimeType) { - "image/png" -> "png" + MimeTypes.Png -> "png" else -> "jpeg" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt new file mode 100644 index 0000000000..d6f4a835bc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview.imageeditor + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.core.net.toUri +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +open class AttachmentImageEditorStateProvider : PreviewParameterProvider { + private val caterpillarCrop = NormalizedCropRect( + left = 0.3f, + top = 0.3f, + right = 0.8f, + bottom = 0.75f, + ) + + override val values: Sequence + get() = sequenceOf( + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + // Cheat a bit so that the crop match the sample image size (1024 * 682) + cropRect = 0.17f.let { correction -> + NormalizedCropRect( + left = 0f, + top = correction, + right = 1f, + bottom = 1 - correction, + ) + }, + ), + ), + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = caterpillarCrop, + ), + ), + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = caterpillarCrop, + ).rotateAntiClockwise(), + ), + ) +} + +private fun anAttachmentImageEditorState( + localMedia: LocalMedia = LocalMedia( + uri = "file://preview-image".toUri(), + info = anImageMediaInfo(), + ), + edits: AttachmentImageEdits = AttachmentImageEdits(), +) = + AttachmentImageEditorState( + localMedia = localMedia, + edits = edits, + ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt index bf3958e83f..7f8a42322a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt @@ -10,20 +10,19 @@ 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 import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding 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 @@ -37,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput @@ -48,18 +48,20 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.R import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Scaffold @@ -67,27 +69,29 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.mediaviewer.api.anImageMediaInfo -import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentImageEditorView( state: AttachmentImageEditorState, onCropRectChange: (NormalizedCropRect) -> Unit, onRotateClick: () -> Unit, + onResetClick: () -> Unit, onCancelClick: () -> Unit, onDoneClick: () -> Unit, modifier: Modifier = Modifier, ) { - val rotateContentDescription = stringResource(R.string.screen_media_upload_preview_rotate) + val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left) val rotationStateDescription = pluralStringResource( - R.plurals.a11y_media_upload_preview_rotation_degrees, + R.plurals.screen_image_edition_a11y_rotation_state, state.edits.rotationDegrees, state.edits.rotationDegrees, ) - val rotateButtonBackground = ElementTheme.colors.bgSubtlePrimary + val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault Scaffold( modifier = modifier.fillMaxSize(), @@ -99,7 +103,14 @@ fun AttachmentImageEditorView( onClick = onCancelClick, ) }, - title = {}, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(R.string.screen_image_edition_title), + ) + }, ) } ) { paddingValues -> @@ -118,79 +129,59 @@ fun AttachmentImageEditorView( onCropRectChange = onCropRectChange, ) } - - Box( + Row( modifier = Modifier - .fillMaxWidth() - .height(132.dp) - .background(ElementTheme.colors.bgCanvasDefault) + .align(Alignment.CenterHorizontally) + .widthIn(max = 360.dp) + .navigationBarsPadding() + .padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterStart, - ) { - TextButton( - text = stringResource(CommonStrings.action_cancel), - onClick = onCancelClick, - ) - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center, - ) { - IconButton( - onClick = onRotateClick, - modifier = Modifier - .size(72.dp) - .background( - color = rotateButtonBackground, - shape = CircleShape, - ) - .clearAndSetSemantics { - contentDescription = rotateContentDescription - stateDescription = rotationStateDescription - } - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Spacer(modifier = Modifier.height(2.dp)) - Icon( - modifier = Modifier - .size(22.dp), - imageVector = CompoundIcons.RotateRight(), - contentDescription = null, - ) - Spacer(modifier = Modifier.height(3.dp)) - Text( - text = "${state.edits.rotationDegrees}°", - style = ElementTheme.typography.fontBodyXsMedium, - color = ElementTheme.colors.textSecondary, - ) + TextButton( + text = stringResource(CommonStrings.action_reset), + destructive = true, + onClick = onResetClick, + ) + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = onRotateClick, + modifier = Modifier + .background( + color = rotateButtonBackground, + shape = CircleShape, + ) + .border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape) + .clearAndSetSemantics { + contentDescription = rotateContentDescription + stateDescription = rotationStateDescription } - } - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterEnd, ) { - TextButton( - text = stringResource(CommonStrings.action_done), - onClick = onDoneClick, + Icon( + modifier = Modifier + .size(22.dp), + imageVector = CompoundIcons.RotateLeft(), + contentDescription = null, ) } } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd, + ) { + TextButton( + text = stringResource(CommonStrings.action_done), + onClick = onDoneClick, + ) + } } } } @@ -207,6 +198,7 @@ private fun CropEditorCanvas( BoxWithConstraints( modifier = Modifier .fillMaxSize() + .padding(20.dp), ) { val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) { val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1 @@ -287,9 +279,9 @@ private fun CropOverlay( ) { var dragTarget by remember { mutableStateOf(null) } val latestCropRect by rememberUpdatedState(cropRect) - val borderColor = ElementTheme.colors.textPrimary - val guideColor = ElementTheme.colors.textSecondary - + val borderColor = ElementTheme.colors.iconPrimary + val guideColor = ElementTheme.colors.iconPrimary + val drawGuidelines = dragTarget == CropDragTarget.Move Canvas( modifier = Modifier .fillMaxSize() @@ -313,7 +305,7 @@ private fun CropOverlay( val activeTarget = dragTarget ?: return@detectDragGestures change.consume() onCropRectChange( - latestCropRect.resize( + latestCropRect.applyChange( dragTarget = activeTarget, deltaX = dragAmount.x / size.width.toFloat(), deltaY = dragAmount.y / size.height.toFloat(), @@ -329,80 +321,120 @@ private fun CropOverlay( // Hardcoded black: the crop overlay must always darken the image regardless of theme. // No semantic token exists for this use case in the Compound design system. val overlayColor = Color.Black.copy(alpha = 0.48f) - + // Overlay above the crop area drawRect( color = overlayColor, topLeft = Offset.Zero, size = Size(width = size.width, height = cropTop), ) + // Overlay on the left of the crop area drawRect( color = overlayColor, topLeft = Offset(0f, cropTop), size = Size(width = cropLeft, height = cropBottom - cropTop), ) + // Overlay on the right of the crop area drawRect( color = overlayColor, topLeft = Offset(cropRight, cropTop), size = Size(width = size.width - cropRight, height = cropBottom - cropTop), ) + // Overlay below the crop area drawRect( color = overlayColor, topLeft = Offset(0f, cropBottom), size = Size(width = size.width, height = size.height - cropBottom), ) - + // Main frame of the crop area drawRect( color = borderColor, topLeft = Offset(cropLeft, cropTop), size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop), - style = Stroke(width = 2.dp.toPx()), + style = Stroke(width = 1.dp.toPx()), ) - - val thirdWidth = (cropRight - cropLeft) / 3f - val thirdHeight = (cropBottom - cropTop) / 3f - repeat(2) { index -> - val offsetX = cropLeft + thirdWidth * (index + 1) - val offsetY = cropTop + thirdHeight * (index + 1) - drawLine( - color = guideColor, - start = Offset(offsetX, cropTop), - end = Offset(offsetX, cropBottom), - strokeWidth = 1.dp.toPx(), - ) - drawLine( - color = guideColor, - start = Offset(cropLeft, offsetY), - end = Offset(cropRight, offsetY), - strokeWidth = 1.dp.toPx(), - ) + // Guide lines dividing the crop area into 9 equal parts + if (drawGuidelines) { + val thirdWidth = (cropRight - cropLeft) / 3f + val thirdHeight = (cropBottom - cropTop) / 3f + (1..2).forEach { index -> + val offsetX = cropLeft + thirdWidth * index + val offsetY = cropTop + thirdHeight * index + // Vertical guide line + drawLine( + color = guideColor, + start = Offset(offsetX, cropTop), + end = Offset(offsetX, cropBottom), + strokeWidth = 1.dp.toPx(), + ) + // Horizontal guide line + drawLine( + color = guideColor, + start = Offset(cropLeft, offsetY), + end = Offset(cropRight, offsetY), + strokeWidth = 1.dp.toPx(), + ) + } } - - val handleLength = 16.dp.toPx() + // Corner handles + val handleLength = 18.dp.toPx() + val handleOffset = 2.dp.toPx() + // Top left corner + drawCornerHandle( + x = cropLeft - handleOffset, + y = cropTop - handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.TopLeft, + ) + // Top right corner + drawCornerHandle( + x = cropRight + handleOffset, + y = cropTop - handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.TopRight, + ) + // Bottom left corner + drawCornerHandle( + x = cropLeft - handleOffset, + y = cropBottom + handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.BottomLeft, + ) + // Bottom right corner + drawCornerHandle( + x = cropRight + handleOffset, + y = cropBottom + handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.BottomRight, + ) val handleColor = borderColor - drawCornerHandle(cropLeft, cropTop, handleLength, handleColor, true, true) - drawCornerHandle(cropRight, cropTop, handleLength, handleColor, false, true) - drawCornerHandle(cropLeft, cropBottom, handleLength, handleColor, true, false) - drawCornerHandle(cropRight, cropBottom, handleLength, handleColor, false, false) + // Top handle drawEdgeHandle( - center = Offset((cropLeft + cropRight) / 2f, cropTop), + center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset), horizontal = true, handleLength = handleLength, color = handleColor, ) + // Right handle drawEdgeHandle( - center = Offset(cropRight, (cropTop + cropBottom) / 2f), + center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f), horizontal = false, handleLength = handleLength, color = handleColor, ) + // Bottom handle drawEdgeHandle( - center = Offset((cropLeft + cropRight) / 2f, cropBottom), + center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset), horizontal = true, handleLength = handleLength, color = handleColor, ) + // Left handle drawEdgeHandle( - center = Offset(cropLeft, (cropTop + cropBottom) / 2f), + center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f), horizontal = false, handleLength = handleLength, color = handleColor, @@ -430,14 +462,14 @@ private fun detectDragTarget( handleTouchRadius: Float, ): CropDragTarget? { val corners = mapOf( - CropDragTarget.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), - CropDragTarget.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), - CropDragTarget.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), - CropDragTarget.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), - CropDragTarget.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), - CropDragTarget.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), - CropDragTarget.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), - CropDragTarget.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + CropDragTarget.Corner.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), + CropDragTarget.Edge.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), + CropDragTarget.Corner.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), + CropDragTarget.Edge.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + CropDragTarget.Corner.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), + CropDragTarget.Edge.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), + CropDragTarget.Corner.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), + CropDragTarget.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), ) corners.forEach { (target, corner) -> if ((corner - touchPoint).getDistance() <= handleTouchRadius) { @@ -455,31 +487,40 @@ private fun detectDragTarget( } } -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCornerHandle( +// x and y are the coordinates of the corner +private fun DrawScope.drawCornerHandle( x: Float, y: Float, handleLength: Float, color: Color, - isLeft: Boolean, - isTop: Boolean, + position: CropDragTarget.Corner, ) { - val horizontalEndX = if (isLeft) x + handleLength else x - handleLength - val verticalEndY = if (isTop) y + handleLength else y - handleLength + val strokeWidth = 4.dp.toPx() + val correction = strokeWidth / 2 + val horizontalCorrection = if (position.isLeft()) -correction else correction + val horizontalEndX = if (position.isLeft()) x + handleLength else x - handleLength + val verticalEndY = if (position.isTop()) y + handleLength else y - handleLength + val verticalCorrection = if (position.isTop()) -correction else correction + // Horizontal line drawLine( color = color, - start = Offset(x, y), - end = Offset(horizontalEndX, y), - strokeWidth = 3.dp.toPx(), + start = Offset(x + horizontalCorrection, y), + end = Offset(horizontalEndX + horizontalCorrection, y), + strokeWidth = strokeWidth, ) + // Vertical line drawLine( color = color, - start = Offset(x, y), - end = Offset(x, verticalEndY), - strokeWidth = 3.dp.toPx(), + start = Offset(x, y + verticalCorrection), + end = Offset(x, verticalEndY + verticalCorrection), + strokeWidth = strokeWidth, ) } -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawEdgeHandle( +private fun CropDragTarget.Corner.isLeft() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.BottomLeft +private fun CropDragTarget.Corner.isTop() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.TopRight + +private fun DrawScope.drawEdgeHandle( center: Offset, horizontal: Boolean, handleLength: Float, @@ -499,23 +540,21 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawEdgeHandle( color = color, start = start, end = end, - strokeWidth = 3.dp.toPx(), + strokeWidth = 4.dp.toPx(), ) } -@PreviewsDayNight +// Only preview in dark, dark theme is forced on the Node. +@Preview @Composable -internal fun AttachmentImageEditorViewPreview() = ElementPreview { +internal fun AttachmentImageEditorViewPreview( + @PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState, +) = ElementPreviewDark { AttachmentImageEditorView( - state = AttachmentImageEditorState( - localMedia = LocalMedia( - uri = "file://preview-image".toUri(), - info = anImageMediaInfo(), - ), - edits = AttachmentImageEdits(), - ), + state = state, onCropRectChange = {}, onRotateClick = {}, + onResetClick = {}, onCancelClick = {}, onDoneClick = {}, ) diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index f5629f5e2d..908646b048 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -16,6 +16,12 @@ "Travel & Places" "Recent emojis" "Symbols" + "Rotate the image to the left" + + "%1$d degree" + "%1$d degrees" + + "Edit photo" "Captions might not be visible to people using older apps." "Tap to change the video upload quality" "The file could not be uploaded." @@ -26,6 +32,7 @@ "Item %1$d of %2$d" "Optimise image quality" "Processing…" + "Add media" "Block user" "Check if you want to hide all current and future messages from this user" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml deleted file mode 100644 index f0050224a9..0000000000 --- a/features/messages/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - Rotate - - %1$d degree - %1$d degrees - - diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 5cbfd331c1..962f800764 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -577,7 +577,7 @@ class AttachmentsPreviewPresenterTest { val editorState = awaitItem() assertThat(editorState.imageEditorState).isNotNull() - editorState.eventSink(AttachmentsPreviewEvent.RotateImage) + editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) val rotatedState = awaitItem() assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(1) @@ -621,7 +621,7 @@ class AttachmentsPreviewPresenterTest { editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) val croppedState = awaitItem() - croppedState.eventSink(AttachmentsPreviewEvent.RotateImage) + croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) val rotatedState = awaitItem() rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt deleted file mode 100644 index fa9c6367f8..0000000000 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.messages.impl.attachments.preview.imageeditor - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.core.mimetype.MimeTypes -import org.junit.Test - -class AttachmentImageEditModelsTest { - @Test - fun `resize with top handle only updates the top edge`() { - val rect = NormalizedCropRect( - left = 0.2f, - top = 0.2f, - right = 0.8f, - bottom = 0.8f, - ) - - val resized = rect.resize( - dragTarget = CropDragTarget.Top, - deltaX = 0.3f, - deltaY = 0.1f, - ) - - assertThat(resized.left).isEqualTo(rect.left) - assertThat(resized.right).isEqualTo(rect.right) - assertThat(resized.bottom).isEqualTo(rect.bottom) - assertThat(resized.top).isEqualTo(0.3f) - } - - @Test - fun `translate keeps the crop rect inside bounds`() { - val rect = NormalizedCropRect( - left = 0.2f, - top = 0.2f, - right = 0.8f, - bottom = 0.8f, - ) - - val translated = rect.translate( - deltaX = 0.6f, - deltaY = 0.6f, - ) - - assertThat(translated.left).isWithin(0.0001f).of(0.4f) - assertThat(translated.top).isWithin(0.0001f).of(0.4f) - assertThat(translated.right).isWithin(0.0001f).of(1.0f) - assertThat(translated.bottom).isWithin(0.0001f).of(1.0f) - } - - @Test - fun `rotate clockwise normalizes after a full turn`() { - var edits = AttachmentImageEdits() - - repeat(4) { - edits = edits.rotateClockwise() - } - - assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0) - assertThat(edits.rotationDegrees).isEqualTo(0) - assertThat(edits.hasChanges).isFalse() - } - - @Test - fun `exported mime type preserves png`() { - assertThat(exportedMimeTypeFor(MimeTypes.Png)).isEqualTo(MimeTypes.Png) - } - - @Test - fun `exported mime type normalizes non-png images to jpeg`() { - assertThat(exportedMimeTypeFor("image/heic")).isEqualTo(MimeTypes.Jpeg) - } -} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt new file mode 100644 index 0000000000..43c893dd05 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview.imageeditor + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AttachmentImageEditsTest { + @Test + fun `rotate normalizes after a full turn`() { + var edits = AttachmentImageEdits() + repeat(4) { + edits = edits.rotateAntiClockwise() + } + assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0) + assertThat(edits.rotationDegrees).isEqualTo(0) + assertThat(edits.hasChanges).isFalse() + } + + @Test + fun `rotate updates rotation and crop`() { + val sut = AttachmentImageEdits( + cropRect = NormalizedCropRect( + left = 0.2f, + top = 0.3f, + right = 0.8f, + bottom = 0.9f, + ), + rotationQuarterTurns = 0, + ) + val result = sut.rotateAntiClockwise() + assertThat(result.normalizedRotationQuarterTurns).isEqualTo(3) + assertThat(result.rotationDegrees).isEqualTo(270) + assertThat(result.cropRect.left).isWithin(0.0001f).of(0.3f) + assertThat(result.cropRect.top).isWithin(0.0001f).of(0.2f) + assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f) + assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f) + assertThat(result.hasChanges).isTrue() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt new file mode 100644 index 0000000000..f9db4f35f9 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview.imageeditor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import org.junit.Test + +class DefaultAttachmentImageEditorTest { + @Test + fun `exported mime type preserves png`() { + assertThat(exportedMimeTypeFor(MimeTypes.Png)).isEqualTo(MimeTypes.Png) + } + + @Test + fun `exported mime type normalizes non-png images to jpeg`() { + assertThat(exportedMimeTypeFor("image/heic")).isEqualTo(MimeTypes.Jpeg) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt new file mode 100644 index 0000000000..b51f10727c --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview.imageeditor + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class NormalizedCropRectTest { + private val rect = NormalizedCropRect( + left = 0.1f, + top = 0.2f, + right = 0.7f, + bottom = 0.8f, + ) + + @Test + fun `applyChange with top handle only updates the top edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Top, + deltaX = 0.3f, + deltaY = 0.1f, + ) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isEqualTo(rect.bottom) + assertThat(result.top).isWithin(0.0001f).of(0.3f) + } + + @Test + fun `applyChange with left handle only updates the left edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Left, + deltaX = 0.1f, + deltaY = 0.3f, + ) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isEqualTo(rect.bottom) + assertThat(result.left).isWithin(0.0001f).of(0.2f) + } + + @Test + fun `applyChange with right handle only updates the right edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Right, + deltaX = -0.1f, + deltaY = 0.3f, + ) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.bottom).isEqualTo(rect.bottom) + assertThat(result.right).isWithin(0.0001f).of(0.6f) + } + + @Test + fun `applyChange with bottom handle target only updates the bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Bottom, + deltaX = -0.1f, + deltaY = -0.3f, + ) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isWithin(0.0001f).of(0.5f) + } + + @Test + fun `applyChange with top left handle updates the top and left bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.TopLeft, + deltaX = 0.1f, + deltaY = 0.1f, + ) + assertThat(result.top).isWithin(0.0001f).of(0.3f) + assertThat(result.left).isWithin(0.0001f).of(0.2f) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isEqualTo(rect.bottom) + } + + @Test + fun `applyChange with top right handle updates the top and right bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.TopRight, + deltaX = -0.1f, + deltaY = 0.1f, + ) + assertThat(result.top).isWithin(0.0001f).of(0.3f) + assertThat(result.right).isWithin(0.0001f).of(0.6f) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.bottom).isEqualTo(rect.bottom) + } + + @Test + fun `applyChange with bottom left handle updates the bottom and left bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.BottomLeft, + deltaX = 0.1f, + deltaY = -0.1f, + ) + assertThat(result.bottom).isWithin(0.0001f).of(0.7f) + assertThat(result.left).isWithin(0.0001f).of(0.2f) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.right).isEqualTo(rect.right) + } + + @Test + fun `applyChange with bottom right handle updates the bottom and right bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.BottomRight, + deltaX = -0.1f, + deltaY = -0.1f, + ) + assertThat(result.bottom).isWithin(0.0001f).of(0.7f) + assertThat(result.right).isWithin(0.0001f).of(0.6f) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.left).isEqualTo(rect.left) + } + + @Test + fun `translate keeps the crop rect inside bounds`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Move, + deltaX = 0.6f, + deltaY = 0.6f, + ) + assertThat(result.left).isWithin(0.0001f).of(0.4f) + assertThat(result.top).isWithin(0.0001f).of(0.4f) + assertThat(result.right).isWithin(0.0001f).of(1.0f) + assertThat(result.bottom).isWithin(0.0001f).of(1.0f) + } +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index e89d45b228..5608486d0e 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -18,6 +18,7 @@ "Info" "Join call" "Jump to bottom" + "Jump to unread" "Move the map to my location" "Mentions only" "Muted" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index cb9c2ecb0d..9936341cee 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -273,6 +273,7 @@ "screen_room_timeline.*", "screen\\.room_timeline.*", "screen_room_typing.*", + "screen\\.image_edition\\..*", "screen\\.media_upload.*" ] },