Update design and UX

Update and add tests
Improve preview
This commit is contained in:
Benoit Marty 2026-05-21 18:04:49 +02:00 committed by Benoit Marty
parent 8851d91ff6
commit 2d5a0b9153
16 changed files with 575 additions and 301 deletions

View file

@ -16,8 +16,9 @@ sealed interface AttachmentsPreviewEvent {
data object CancelAndClearSendState : AttachmentsPreviewEvent data object CancelAndClearSendState : AttachmentsPreviewEvent
data object OpenImageEditor : AttachmentsPreviewEvent data object OpenImageEditor : AttachmentsPreviewEvent
data object CloseImageEditor : AttachmentsPreviewEvent data object CloseImageEditor : AttachmentsPreviewEvent
data object RotateImage : AttachmentsPreviewEvent data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent data object ClearImageEditError : AttachmentsPreviewEvent
} }

View file

@ -287,10 +287,15 @@ class AttachmentsPreviewPresenter(
edits = pendingState.edits.copy(cropRect = event.cropRect) edits = pendingState.edits.copy(cropRect = event.cropRect)
) )
} }
AttachmentsPreviewEvent.RotateImage -> { AttachmentsPreviewEvent.RotateImageToTheLeft -> {
val pendingState = imageEditorState ?: return val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy( imageEditorState = pendingState.copy(
edits = pendingState.edits.rotateClockwise() edits = pendingState.edits.rotateAntiClockwise()
)
}
AttachmentsPreviewEvent.ResetImageEdits -> {
imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits()
) )
} }
AttachmentsPreviewEvent.ApplyImageEdits -> { AttachmentsPreviewEvent.ApplyImageEdits -> {

View file

@ -31,11 +31,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.R
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError 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.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.Switch
import io.element.android.libraries.designsystem.theme.components.Text 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.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
@ -110,6 +113,10 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.CloseImageEditor) state.eventSink(AttachmentsPreviewEvent.CloseImageEditor)
} }
fun postResetImageEditor() {
state.eventSink(AttachmentsPreviewEvent.ResetImageEdits)
}
fun postApplyImageEdits() { fun postApplyImageEdits() {
state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
} }
@ -128,8 +135,9 @@ fun AttachmentsPreviewView(
onCropRectChange = { cropRect -> onCropRectChange = { cropRect ->
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
}, },
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImage) }, onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
onCancelClick = ::postCloseImageEditor, onCancelClick = ::postCloseImageEditor,
onResetClick = ::postResetImageEditor,
onDoneClick = ::postApplyImageEdits, onDoneClick = ::postApplyImageEdits,
modifier = modifier, modifier = modifier,
) )
@ -140,19 +148,23 @@ fun AttachmentsPreviewView(
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
BackButton( BackButton(
imageVector = CompoundIcons.Close(),
onClick = ::postCancel, onClick = ::postCancel,
) )
}, },
title = {}, title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_media_upload_preview_title),
)
},
actions = { actions = {
if (state.canEditImage && canShowEditAction) { if (state.canEditImage && canShowEditAction) {
IconButton(onClick = ::postOpenImageEditor) { TextButton(
Icon( stringResource(CommonStrings.action_edit),
imageVector = CompoundIcons.Edit(), onClick = ::postOpenImageEditor
contentDescription = stringResource(CommonStrings.action_edit), )
)
}
} }
} }
) )
@ -202,32 +214,32 @@ private fun AttachmentSendStateView(
) )
} }
else -> when (sendActionState) { else -> when (sendActionState) {
is SendActionState.Sending.Processing -> { is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) { if (sendActionState.displayProgress) {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
}
is SendActionState.Sending.Uploading -> {
ProgressDialog( ProgressDialog(
type = ProgressDialogType.Indeterminate, type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing), text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true, showCancelButton = true,
onDismissRequest = onDismissClick, onDismissRequest = onDismissClick,
) )
} }
} is SendActionState.Failure -> {
is SendActionState.Sending.Uploading -> { RetryDialog(
ProgressDialog( content = stringResource(sendAttachmentError(sendActionState.error)),
type = ProgressDialogType.Indeterminate, onDismiss = onDismissClick,
text = stringResource(id = CommonStrings.common_sending), onRetry = onRetryClick
showCancelButton = true, )
onDismissRequest = onDismissClick, }
) 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) { private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
if (state.displayMediaSelectorViews == true) { if (state.displayMediaSelectorViews == true) {
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.niceClickable { .niceClickable {
state.isImageOptimizationEnabled?.let { value -> state.isImageOptimizationEnabled?.let { value ->
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value)) state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
@ -300,7 +313,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
.padding(horizontal = 16.dp, vertical = 16.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
) { ) {
Text( 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), text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
) )
@ -326,7 +341,8 @@ private fun VideoPresetSelector(
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) { if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) } .niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
) { ) {

View file

@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia 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 private const val MIN_CROP_SIZE = 0.1f
@Immutable @Immutable
@ -25,7 +25,7 @@ data class AttachmentImageEdits(
val rotationQuarterTurns: Int = 0, val rotationQuarterTurns: Int = 0,
) { ) {
val normalizedRotationQuarterTurns: Int val normalizedRotationQuarterTurns: Int
get() = (rotationQuarterTurns % 4 + 4) % 4 get() = rotationQuarterTurns % 4
val rotationDegrees: Int val rotationDegrees: Int
get() = normalizedRotationQuarterTurns * 90 get() = normalizedRotationQuarterTurns * 90
@ -33,8 +33,17 @@ data class AttachmentImageEdits(
val hasChanges: Boolean val hasChanges: Boolean
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
fun rotateClockwise(): AttachmentImageEdits { fun rotateAntiClockwise(): AttachmentImageEdits {
return copy(rotationQuarterTurns = (normalizedRotationQuarterTurns + 1) % 4) 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 val height: Float
get() = bottom - top 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 clampedLeft = (left + deltaX).coerceIn(0f, 1f - width)
val clampedTop = (top + deltaY).coerceIn(0f, 1f - height) val clampedTop = (top + deltaY).coerceIn(0f, 1f - height)
return copy( return copy(
@ -71,34 +86,36 @@ data class NormalizedCropRect(
) )
} }
fun resize(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { private fun dragWithCorner(dragTarget: CropDragTarget.Corner, deltaX: Float, deltaY: Float) = when (dragTarget) {
CropDragTarget.Move -> translate(deltaX, deltaY) CropDragTarget.Corner.TopLeft -> copy(
CropDragTarget.TopLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
) )
CropDragTarget.Top -> copy( CropDragTarget.Corner.TopRight -> copy(
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.TopRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
) )
CropDragTarget.Right -> copy( CropDragTarget.Corner.BottomRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
)
CropDragTarget.BottomRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
) )
CropDragTarget.Bottom -> copy( CropDragTarget.Corner.BottomLeft -> copy(
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.BottomLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), 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), left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
) )
} }
@ -113,14 +130,20 @@ data class NormalizedCropRect(
} }
} }
enum class CropDragTarget { sealed interface CropDragTarget {
Move, data object Move : CropDragTarget
TopLeft,
Top, sealed interface Corner : CropDragTarget {
TopRight, data object TopLeft : Corner
Right, data object TopRight : Corner
BottomRight, data object BottomRight : Corner
Bottom, data object BottomLeft : Corner
BottomLeft, }
Left,
sealed interface Edge : CropDragTarget {
data object Top : Edge
data object Right : Edge
data object Bottom : Edge
data object Left : Edge
}
} }

View file

@ -174,12 +174,12 @@ private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): P
} }
private fun compressFormat(mimeType: String) = when (mimeType) { private fun compressFormat(mimeType: String) = when (mimeType) {
"image/png" -> Bitmap.CompressFormat.PNG MimeTypes.Png -> Bitmap.CompressFormat.PNG
else -> Bitmap.CompressFormat.JPEG else -> Bitmap.CompressFormat.JPEG
} }
private fun compressFileExtension(mimeType: String) = when (mimeType) { private fun compressFileExtension(mimeType: String) = when (mimeType) {
"image/png" -> "png" MimeTypes.Png -> "png"
else -> "jpeg" else -> "jpeg"
} }

View file

@ -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<AttachmentImageEditorState> {
private val caterpillarCrop = NormalizedCropRect(
left = 0.3f,
top = 0.3f,
right = 0.8f,
bottom = 0.75f,
)
override val values: Sequence<AttachmentImageEditorState>
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,
)

View file

@ -10,20 +10,19 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable 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.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color 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.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput 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.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription 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.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.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
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.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables 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 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AttachmentImageEditorView( fun AttachmentImageEditorView(
state: AttachmentImageEditorState, state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit, onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit, onRotateClick: () -> Unit,
onResetClick: () -> Unit,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
onDoneClick: () -> Unit, onDoneClick: () -> Unit,
modifier: Modifier = Modifier, 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( 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,
state.edits.rotationDegrees, state.edits.rotationDegrees,
) )
val rotateButtonBackground = ElementTheme.colors.bgSubtlePrimary val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@ -99,7 +103,14 @@ fun AttachmentImageEditorView(
onClick = onCancelClick, onClick = onCancelClick,
) )
}, },
title = {}, title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_image_edition_title),
)
},
) )
} }
) { paddingValues -> ) { paddingValues ->
@ -118,79 +129,59 @@ fun AttachmentImageEditorView(
onCropRectChange = onCropRectChange, onCropRectChange = onCropRectChange,
) )
} }
Row(
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .align(Alignment.CenterHorizontally)
.height(132.dp) .widthIn(max = 360.dp)
.background(ElementTheme.colors.bgCanvasDefault) .navigationBarsPadding()
.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Row( Box(
modifier = Modifier modifier = Modifier.weight(1f),
.fillMaxWidth() contentAlignment = Alignment.CenterStart,
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( TextButton(
modifier = Modifier.weight(1f), text = stringResource(CommonStrings.action_reset),
contentAlignment = Alignment.CenterStart, destructive = true,
) { onClick = onResetClick,
TextButton( )
text = stringResource(CommonStrings.action_cancel), }
onClick = onCancelClick, Box(
) modifier = Modifier.weight(1f),
} contentAlignment = Alignment.Center,
Box( ) {
modifier = Modifier.weight(1f), IconButton(
contentAlignment = Alignment.Center, onClick = onRotateClick,
) { modifier = Modifier
IconButton( .background(
onClick = onRotateClick, color = rotateButtonBackground,
modifier = Modifier shape = CircleShape,
.size(72.dp) )
.background( .border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
color = rotateButtonBackground, .clearAndSetSemantics {
shape = CircleShape, contentDescription = rotateContentDescription
) stateDescription = rotationStateDescription
.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,
)
} }
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) { ) {
TextButton( Icon(
text = stringResource(CommonStrings.action_done), modifier = Modifier
onClick = onDoneClick, .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( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(20.dp),
) { ) {
val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) { val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) {
val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1 val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1
@ -287,9 +279,9 @@ private fun CropOverlay(
) { ) {
var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) } var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) }
val latestCropRect by rememberUpdatedState(cropRect) val latestCropRect by rememberUpdatedState(cropRect)
val borderColor = ElementTheme.colors.textPrimary val borderColor = ElementTheme.colors.iconPrimary
val guideColor = ElementTheme.colors.textSecondary val guideColor = ElementTheme.colors.iconPrimary
val drawGuidelines = dragTarget == CropDragTarget.Move
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -313,7 +305,7 @@ private fun CropOverlay(
val activeTarget = dragTarget ?: return@detectDragGestures val activeTarget = dragTarget ?: return@detectDragGestures
change.consume() change.consume()
onCropRectChange( onCropRectChange(
latestCropRect.resize( latestCropRect.applyChange(
dragTarget = activeTarget, dragTarget = activeTarget,
deltaX = dragAmount.x / size.width.toFloat(), deltaX = dragAmount.x / size.width.toFloat(),
deltaY = dragAmount.y / size.height.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. // 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. // No semantic token exists for this use case in the Compound design system.
val overlayColor = Color.Black.copy(alpha = 0.48f) val overlayColor = Color.Black.copy(alpha = 0.48f)
// Overlay above the crop area
drawRect( drawRect(
color = overlayColor, color = overlayColor,
topLeft = Offset.Zero, topLeft = Offset.Zero,
size = Size(width = size.width, height = cropTop), size = Size(width = size.width, height = cropTop),
) )
// Overlay on the left of the crop area
drawRect( drawRect(
color = overlayColor, color = overlayColor,
topLeft = Offset(0f, cropTop), topLeft = Offset(0f, cropTop),
size = Size(width = cropLeft, height = cropBottom - cropTop), size = Size(width = cropLeft, height = cropBottom - cropTop),
) )
// Overlay on the right of the crop area
drawRect( drawRect(
color = overlayColor, color = overlayColor,
topLeft = Offset(cropRight, cropTop), topLeft = Offset(cropRight, cropTop),
size = Size(width = size.width - cropRight, height = cropBottom - cropTop), size = Size(width = size.width - cropRight, height = cropBottom - cropTop),
) )
// Overlay below the crop area
drawRect( drawRect(
color = overlayColor, color = overlayColor,
topLeft = Offset(0f, cropBottom), topLeft = Offset(0f, cropBottom),
size = Size(width = size.width, height = size.height - cropBottom), size = Size(width = size.width, height = size.height - cropBottom),
) )
// Main frame of the crop area
drawRect( drawRect(
color = borderColor, color = borderColor,
topLeft = Offset(cropLeft, cropTop), topLeft = Offset(cropLeft, cropTop),
size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop), size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop),
style = Stroke(width = 2.dp.toPx()), style = Stroke(width = 1.dp.toPx()),
) )
// Guide lines dividing the crop area into 9 equal parts
val thirdWidth = (cropRight - cropLeft) / 3f if (drawGuidelines) {
val thirdHeight = (cropBottom - cropTop) / 3f val thirdWidth = (cropRight - cropLeft) / 3f
repeat(2) { index -> val thirdHeight = (cropBottom - cropTop) / 3f
val offsetX = cropLeft + thirdWidth * (index + 1) (1..2).forEach { index ->
val offsetY = cropTop + thirdHeight * (index + 1) val offsetX = cropLeft + thirdWidth * index
drawLine( val offsetY = cropTop + thirdHeight * index
color = guideColor, // Vertical guide line
start = Offset(offsetX, cropTop), drawLine(
end = Offset(offsetX, cropBottom), color = guideColor,
strokeWidth = 1.dp.toPx(), start = Offset(offsetX, cropTop),
) end = Offset(offsetX, cropBottom),
drawLine( strokeWidth = 1.dp.toPx(),
color = guideColor, )
start = Offset(cropLeft, offsetY), // Horizontal guide line
end = Offset(cropRight, offsetY), drawLine(
strokeWidth = 1.dp.toPx(), color = guideColor,
) start = Offset(cropLeft, offsetY),
end = Offset(cropRight, offsetY),
strokeWidth = 1.dp.toPx(),
)
}
} }
// Corner handles
val handleLength = 16.dp.toPx() 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 val handleColor = borderColor
drawCornerHandle(cropLeft, cropTop, handleLength, handleColor, true, true) // Top handle
drawCornerHandle(cropRight, cropTop, handleLength, handleColor, false, true)
drawCornerHandle(cropLeft, cropBottom, handleLength, handleColor, true, false)
drawCornerHandle(cropRight, cropBottom, handleLength, handleColor, false, false)
drawEdgeHandle( drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropTop), center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset),
horizontal = true, horizontal = true,
handleLength = handleLength, handleLength = handleLength,
color = handleColor, color = handleColor,
) )
// Right handle
drawEdgeHandle( drawEdgeHandle(
center = Offset(cropRight, (cropTop + cropBottom) / 2f), center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false, horizontal = false,
handleLength = handleLength, handleLength = handleLength,
color = handleColor, color = handleColor,
) )
// Bottom handle
drawEdgeHandle( drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropBottom), center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset),
horizontal = true, horizontal = true,
handleLength = handleLength, handleLength = handleLength,
color = handleColor, color = handleColor,
) )
// Left handle
drawEdgeHandle( drawEdgeHandle(
center = Offset(cropLeft, (cropTop + cropBottom) / 2f), center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false, horizontal = false,
handleLength = handleLength, handleLength = handleLength,
color = handleColor, color = handleColor,
@ -430,14 +462,14 @@ private fun detectDragTarget(
handleTouchRadius: Float, handleTouchRadius: Float,
): CropDragTarget? { ): CropDragTarget? {
val corners = mapOf( val corners = mapOf(
CropDragTarget.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), CropDragTarget.Corner.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.Edge.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.Corner.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.Edge.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.Corner.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.Edge.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.Corner.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.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f),
) )
corners.forEach { (target, corner) -> corners.forEach { (target, corner) ->
if ((corner - touchPoint).getDistance() <= handleTouchRadius) { 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, x: Float,
y: Float, y: Float,
handleLength: Float, handleLength: Float,
color: Color, color: Color,
isLeft: Boolean, position: CropDragTarget.Corner,
isTop: Boolean,
) { ) {
val horizontalEndX = if (isLeft) x + handleLength else x - handleLength val strokeWidth = 4.dp.toPx()
val verticalEndY = if (isTop) y + handleLength else y - handleLength 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( drawLine(
color = color, color = color,
start = Offset(x, y), start = Offset(x + horizontalCorrection, y),
end = Offset(horizontalEndX, y), end = Offset(horizontalEndX + horizontalCorrection, y),
strokeWidth = 3.dp.toPx(), strokeWidth = strokeWidth,
) )
// Vertical line
drawLine( drawLine(
color = color, color = color,
start = Offset(x, y), start = Offset(x, y + verticalCorrection),
end = Offset(x, verticalEndY), end = Offset(x, verticalEndY + verticalCorrection),
strokeWidth = 3.dp.toPx(), 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, center: Offset,
horizontal: Boolean, horizontal: Boolean,
handleLength: Float, handleLength: Float,
@ -499,23 +540,21 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawEdgeHandle(
color = color, color = color,
start = start, start = start,
end = end, end = end,
strokeWidth = 3.dp.toPx(), strokeWidth = 4.dp.toPx(),
) )
} }
@PreviewsDayNight // Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable @Composable
internal fun AttachmentImageEditorViewPreview() = ElementPreview { internal fun AttachmentImageEditorViewPreview(
@PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState,
) = ElementPreviewDark {
AttachmentImageEditorView( AttachmentImageEditorView(
state = AttachmentImageEditorState( state = state,
localMedia = LocalMedia(
uri = "file://preview-image".toUri(),
info = anImageMediaInfo(),
),
edits = AttachmentImageEdits(),
),
onCropRectChange = {}, onCropRectChange = {},
onRotateClick = {}, onRotateClick = {},
onResetClick = {},
onCancelClick = {}, onCancelClick = {},
onDoneClick = {}, onDoneClick = {},
) )

View file

@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Travel &amp; Places"</string> <string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string> <string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string> <string name="emoji_picker_category_symbols">"Symbols"</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>
<item quantity="other">"%1$d degrees"</item>
</plurals>
<string name="screen_image_edition_title">"Edit photo"</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string> <string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string> <string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"The file could not be uploaded."</string> <string name="screen_media_upload_preview_error_could_not_be_uploaded">"The file could not be uploaded."</string>
@ -26,6 +32,7 @@
<string name="screen_media_upload_preview_item_count">"Item %1$d of %2$d"</string> <string name="screen_media_upload_preview_item_count">"Item %1$d of %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimise image quality"</string> <string name="screen_media_upload_preview_optimize_image_quality_title">"Optimise image quality"</string>
<string name="screen_media_upload_preview_processing">"Processing…"</string> <string name="screen_media_upload_preview_processing">"Processing…"</string>
<string name="screen_media_upload_preview_title">"Add media"</string>
<string name="screen_report_content_block_user">"Block user"</string> <string name="screen_report_content_block_user">"Block user"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string> <string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_report_content_explanation">"This message will be reported to your homeservers administrator. They will not be able to read any encrypted messages."</string> <string name="screen_report_content_explanation">"This message will be reported to your homeservers administrator. They will not be able to read any encrypted messages."</string>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="screen_media_upload_preview_rotate">Rotate</string>
<plurals name="a11y_media_upload_preview_rotation_degrees">
<item quantity="one">%1$d degree</item>
<item quantity="other">%1$d degrees</item>
</plurals>
</resources>

View file

@ -577,7 +577,7 @@ class AttachmentsPreviewPresenterTest {
val editorState = awaitItem() val editorState = awaitItem()
assertThat(editorState.imageEditorState).isNotNull() assertThat(editorState.imageEditorState).isNotNull()
editorState.eventSink(AttachmentsPreviewEvent.RotateImage) editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem() val rotatedState = awaitItem()
assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(1) assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(1)
@ -621,7 +621,7 @@ class AttachmentsPreviewPresenterTest {
editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
val croppedState = awaitItem() val croppedState = awaitItem()
croppedState.eventSink(AttachmentsPreviewEvent.RotateImage) croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem() val rotatedState = awaitItem()
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -18,6 +18,7 @@
<string name="a11y_info">"Info"</string> <string name="a11y_info">"Info"</string>
<string name="a11y_join_call">"Join call"</string> <string name="a11y_join_call">"Join call"</string>
<string name="a11y_jump_to_bottom">"Jump to bottom"</string> <string name="a11y_jump_to_bottom">"Jump to bottom"</string>
<string name="a11y_jump_to_unread">"Jump to unread"</string>
<string name="a11y_move_the_map_to_my_location">"Move the map to my location"</string> <string name="a11y_move_the_map_to_my_location">"Move the map to my location"</string>
<string name="a11y_notifications_mentions_only">"Mentions only"</string> <string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</string> <string name="a11y_notifications_muted">"Muted"</string>

View file

@ -273,6 +273,7 @@
"screen_room_timeline.*", "screen_room_timeline.*",
"screen\\.room_timeline.*", "screen\\.room_timeline.*",
"screen_room_typing.*", "screen_room_typing.*",
"screen\\.image_edition\\..*",
"screen\\.media_upload.*" "screen\\.media_upload.*"
] ]
}, },