Update design and UX
Update and add tests Improve preview
This commit is contained in:
parent
8851d91ff6
commit
2d5a0b9153
16 changed files with 575 additions and 301 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@
|
||||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
<string name="emoji_picker_category_places">"Travel & 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 homeserver’s 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 homeserver’s administrator. They will not be able to read any encrypted messages."</string>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue