From e376c1be82d4e164d5b5c6f1e5ca8de689ed668b Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 7 Jun 2023 13:06:19 +0200 Subject: [PATCH] [Message actions] New UI for message composer and editing (#526) * Add UI for edit composer mode * Remove leftover code from the RTE implementation --------- Co-authored-by: ElementBot --- .maestro/tests/roomList/searchRoomList.yaml | 2 - .../tests/roomList/timeline/timeline.yaml | 4 +- changelog.d/484.feature | 1 + .../messagecomposer/MessageComposerView.kt | 6 +- .../designsystem/theme/ColorsDark.kt | 1 + .../designsystem/theme/ColorsLight.kt | 1 + .../designsystem/theme/ElementColors.kt | 7 + .../theme/components/OutlinedTextField.kt | 48 ++ .../theme/components/TextField.kt | 48 ++ .../textcomposer/MessageComposerView.kt | 56 -- .../textcomposer/RichTextComposerLayout.kt | 552 ------------------ .../libraries/textcomposer/TextComposer.kt | 374 ++++++++---- .../textcomposer/tools/EditTextExtensions.kt | 55 -- .../tools/SimpleTransitionListener.kt | 41 -- .../textcomposer/tools/ViewExtensions.kt | 39 -- .../main/res/drawable/ic_add_attachment.xml | 10 + .../src/main/res/drawable/ic_send.xml | 9 + .../src/main/res/drawable/ic_tick.xml | 9 + .../kotlin/extension/DependencyHandleScope.kt | 1 + ...ewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...wLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...ewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...ewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 4 +- ...ewDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 4 +- ...wLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...wLightPreview_0_null_1,NEXUS_5,1.0,en].png | 4 +- ...wLightPreview_0_null_2,NEXUS_5,1.0,en].png | 4 +- ...oserDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...serLightPreview_0_null,NEXUS_5,1.0,en].png | 3 + 29 files changed, 425 insertions(+), 877 deletions(-) create mode 100644 changelog.d/484.feature delete mode 100644 libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerView.kt delete mode 100644 libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt delete mode 100644 libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/EditTextExtensions.kt delete mode 100644 libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/SimpleTransitionListener.kt delete mode 100644 libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/ViewExtensions.kt create mode 100644 libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml create mode 100644 libraries/textcomposer/src/main/res/drawable/ic_send.xml create mode 100644 libraries/textcomposer/src/main/res/drawable/ic_tick.xml create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml index 45138eb9aa..5125109197 100644 --- a/.maestro/tests/roomList/searchRoomList.yaml +++ b/.maestro/tests/roomList/searchRoomList.yaml @@ -5,8 +5,6 @@ appId: ${APP_ID} - inputText: ${ROOM_NAME.substring(0, 3)} - takeScreenshot: build/maestro/400-SearchRoom - tapOn: ${ROOM_NAME} -# Close keyboard -- hideKeyboard # Back from timeline - back # Close keyboard diff --git a/.maestro/tests/roomList/timeline/timeline.yaml b/.maestro/tests/roomList/timeline/timeline.yaml index 95d1c1ff57..e17bcb35cf 100644 --- a/.maestro/tests/roomList/timeline/timeline.yaml +++ b/.maestro/tests/roomList/timeline/timeline.yaml @@ -4,10 +4,8 @@ appId: ${APP_ID} # TODO Create a room on a new account - tapOn: ${ROOM_NAME} - takeScreenshot: build/maestro/500-Timeline -- tapOn: "Message…" +- tapOn: "Message" - inputText: "Hello world!" -- tapOn: "Toggle full screen mode" -- tapOn: "Toggle full screen mode" - tapOn: "Send" - hideKeyboard - back diff --git a/changelog.d/484.feature b/changelog.d/484.feature new file mode 100644 index 0000000000..d12d4f1547 --- /dev/null +++ b/changelog.d/484.feature @@ -0,0 +1 @@ +New UI for composer and editing messages diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index a66e7d06ab..a317ea1ed3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.textcomposer.TextComposer @Composable @@ -52,17 +51,14 @@ fun MessageComposerView( TextComposer( onSendMessage = ::sendMessage, - fullscreen = state.isFullScreen, - onFullscreenToggle = ::onFullscreenToggle, composerMode = state.mode, - onCloseSpecialMode = ::onCloseSpecialMode, + onResetComposerMode = ::onCloseSpecialMode, onComposerTextChange = ::onComposerTextChange, onAddAttachment = { state.eventSink(MessageComposerEvents.AddAttachment) }, composerCanSendMessage = state.isSendButtonVisible, composerText = state.text?.charSequence?.toString(), - isInDarkMode = !ElementTheme.colors.isLight, modifier = modifier ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt index b24f7fad89..c9aade676d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsDark.kt @@ -46,6 +46,7 @@ fun elementColorsDark() = ElementColors( gray400 = Compound_Gray_400_Dark, gray1400 = Compound_Gray_1400_Dark, textActionCritical = TextColorCriticalDark, + accentColor = Color(0xFF0DBD8B), placeholder = Compound_Gray_800_Dark, isLight = false, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt index c35037febd..94aca1b0f0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorsLight.kt @@ -46,6 +46,7 @@ fun elementColorsLight() = ElementColors( gray400 = Compound_Gray_400_Light, gray1400 = Compound_Gray_1400_Light, textActionCritical = TextColorCriticalLight, + accentColor = Color(0xFF0DBD8B), placeholder = Compound_Gray_800_Light, isLight = true, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt index 7eb4dfc0d6..629242ad26 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementColors.kt @@ -33,6 +33,7 @@ class ElementColors( gray400: Color, gray1400: Color, textActionCritical: Color, + accentColor: Color, placeholder: Color, isLight: Boolean ) { @@ -61,6 +62,9 @@ class ElementColors( var textActionCritical by mutableStateOf(textActionCritical) private set + var accentColor by mutableStateOf(accentColor) + private set + var placeholder by mutableStateOf(placeholder) private set @@ -77,6 +81,7 @@ class ElementColors( gray400: Color = this.gray400, gray1400: Color = this.gray1400, textActionCritical: Color = this.textActionCritical, + accentColor: Color = this.accentColor, placeholder: Color = this.placeholder, isLight: Boolean = this.isLight, ) = ElementColors( @@ -89,6 +94,7 @@ class ElementColors( gray400 = gray400, gray1400 = gray1400, textActionCritical = textActionCritical, + accentColor = accentColor, placeholder = placeholder, isLight = isLight, ) @@ -103,6 +109,7 @@ class ElementColors( gray400 = other.gray400 gray1400 = other.gray1400 textActionCritical = other.textActionCritical + accentColor = other.accentColor placeholder = other.placeholder isLight = other.isLight } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt index e0edeed050..423d1e46d1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -95,6 +96,53 @@ fun OutlinedTextField( ) } +@Composable +fun OutlinedTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = Int.MAX_VALUE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) { + androidx.compose.material3.OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + @OptIn(ExperimentalComposeUiApi::class) fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event -> if (event.key == Key.Tab || event.key == Key.Enter) { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index 54fe50b8bf..8376369fba 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -96,6 +97,53 @@ fun TextField( ) } +@Composable +fun TextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = Int.MAX_VALUE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + androidx.compose.material3.TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + @Preview(group = PreviewGroup.TextFields) @Composable internal fun TextFieldLightPreview() = diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerView.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerView.kt deleted file mode 100644 index d646a09ffc..0000000000 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerView.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.textcomposer - -import android.net.Uri -import android.text.Editable -import android.widget.EditText -import android.widget.ImageButton - -// Imported from Element Android -interface MessageComposerView { - - companion object { - const val MAX_LINES_WHEN_COLLAPSED = 10 - } - - val text: Editable? - val formattedText: String? - val editText: EditText - val emojiButton: ImageButton? - val sendButton: ImageButton - val attachmentButton: ImageButton - - var callback: Callback? - - fun setTextIfDifferent(text: CharSequence?): Boolean - fun renderComposerMode(mode: MessageComposerMode) -} - -interface Callback { - // From ComposerEditText.Callback - fun onRichContentSelected(contentUri: Uri): Boolean - - // From ComposerEditText.Callback - fun onTextChanged(text: CharSequence) - - fun onCloseRelatedMessage() - fun onSendMessage(text: CharSequence) - fun onAddAttachment() - fun onExpandOrCompactChange() - fun onFullScreenModeChanged() -} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt deleted file mode 100644 index e5ab34ebfe..0000000000 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/RichTextComposerLayout.kt +++ /dev/null @@ -1,552 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.textcomposer - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.ColorStateList -import android.content.res.Configuration -import android.graphics.Color -import android.text.Editable -import android.text.TextWatcher -import android.util.AttributeSet -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.widget.EditText -import android.widget.ImageButton -import android.widget.LinearLayout -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.constraintlayout.widget.ConstraintSet -import androidx.core.view.isGone -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import com.google.android.material.shape.MaterialShapeDrawable -import io.element.android.libraries.androidutils.ui.DimensionConverter -import io.element.android.libraries.androidutils.ui.hideKeyboard -import io.element.android.libraries.androidutils.ui.showKeyboard -import io.element.android.libraries.textcomposer.databinding.ComposerRichTextLayoutBinding -import io.element.android.libraries.textcomposer.databinding.ViewRichTextMenuButtonBinding -import io.element.android.libraries.textcomposer.tools.setTextIfDifferent -import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.view.models.InlineFormat -import uniffi.wysiwyg_composer.ActionState -import uniffi.wysiwyg_composer.ComposerAction -import io.element.android.libraries.resources.R as ElementR -import io.element.android.libraries.ui.strings.R as StringR - -// Imported from Element Android -class RichTextComposerLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { - - private val views: ComposerRichTextLayoutBinding - - override var callback: Callback? = null - - // There is no need to persist these values since they're always updated by the parent fragment - private var isFullScreen = false - private var hasRelatedMessage = false - private var composerMode: MessageComposerMode? = null - - var isTextFormattingEnabled = true - set(value) { - if (field == value) return - syncEditTexts() - field = value - updateTextFieldBorder(isFullScreen) - updateEditTextVisibility() - updateFullScreenButtonVisibility() - // If formatting is no longer enabled and it's in full screen, minimise the editor - if (!value && isFullScreen) { - callback?.onFullScreenModeChanged() - } - } - - override val text: Editable? - get() = editText.text - override val formattedText: String? - get() = (editText as? EditorEditText)?.getHtmlOutput() - override val editText: EditText - get() = if (isTextFormattingEnabled) { - views.richTextComposerEditText - } else { - views.plainTextComposerEditText - } - override val emojiButton: ImageButton? - get() = null - override val sendButton: ImageButton - get() = views.sendButton - override val attachmentButton: ImageButton - get() = views.attachmentButton - - // Border of the EditText - private val borderShapeDrawable: MaterialShapeDrawable by lazy { - MaterialShapeDrawable().apply { - val typedData = TypedValue() - val lineColor = context.theme.obtainStyledAttributes( - typedData.data, - intArrayOf(ElementR.attr.vctr_content_quaternary) - ) - .getColor(0, 0) - strokeColor = ColorStateList.valueOf(lineColor) - strokeWidth = 1 * resources.displayMetrics.scaledDensity - fillColor = ColorStateList.valueOf(Color.TRANSPARENT) - val cornerSize = - resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) - setCornerSize(cornerSize.toFloat()) - } - } - - private val dimensionConverter = DimensionConverter(resources) - - fun setFullScreen(isFullScreen: Boolean, animated: Boolean, manageKeyboard: Boolean) { - if (!animated && views.composerLayout.layoutParams != null) { - views.composerLayout.updateLayoutParams { - height = - if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT - } - } - editText.updateLayoutParams { - height = if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT - } - - updateTextFieldBorder(isFullScreen) - updateEditTextVisibility() - - updateEditTextFullScreenState(views.richTextComposerEditText, isFullScreen) - updateEditTextFullScreenState(views.plainTextComposerEditText, isFullScreen) - - views.composerFullScreenButton.setImageResource( - if (isFullScreen) R.drawable.ic_composer_collapse else R.drawable.ic_composer_full_screen - ) - - views.bottomSheetHandle.isVisible = false // EAx: always gone, we do not have a bottom sheet here. // isFullScreen - if (manageKeyboard) { - if (isFullScreen) { - editText.showKeyboard(true) - } else { - editText.hideKeyboard() - } - } - - this.isFullScreen = isFullScreen - } - - fun notifyIsBeingDragged(percentage: Float) { - // Calculate a new shape for the border according to the position in screen - val isSingleLine = editText.lineCount == 1 - val cornerSize = if (!isSingleLine || hasRelatedMessage) { - resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) - .toFloat() - } else { - val multilineCornerSize = - resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) - val singleLineCornerSize = - resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) - val diff = singleLineCornerSize - multilineCornerSize - multilineCornerSize + diff * (1 - percentage) - } - if (cornerSize != borderShapeDrawable.bottomLeftCornerResolvedSize) { - borderShapeDrawable.setCornerSize(cornerSize) - } - - // Change maxLines while dragging, this should improve the smoothness of animations - val maxLines = if (percentage > 0.25f) { - Int.MAX_VALUE - } else { - MessageComposerView.MAX_LINES_WHEN_COLLAPSED - } - views.richTextComposerEditText.maxLines = maxLines - views.plainTextComposerEditText.maxLines = maxLines - - views.bottomSheetHandle.isVisible = false // EAx: always gone, we do not have a bottom sheet here. - } - - init { - inflate(context, R.layout.composer_rich_text_layout, this) - views = ComposerRichTextLayoutBinding.bind(this) - - // Workaround to avoid cut-off text caused by padding in scrolled TextView (there is no clipToPadding). - // In TextView, clipTop = padding, but also clipTop -= shadowRadius. So if we set the shadowRadius to padding, they cancel each other - views.richTextComposerEditText.setShadowLayer( - views.richTextComposerEditText.paddingBottom.toFloat(), - 0f, - 0f, - 0 - ) - views.plainTextComposerEditText.setShadowLayer( - views.richTextComposerEditText.paddingBottom.toFloat(), - 0f, - 0f, - 0 - ) - - renderComposerMode(MessageComposerMode.Normal(null)) - - views.richTextComposerEditText.addTextChangedListener( - TextChangeListener( - { callback?.onTextChanged(it) }, - { updateTextFieldBorder(isFullScreen) }) - ) - views.plainTextComposerEditText.addTextChangedListener( - TextChangeListener( - { callback?.onTextChanged(it) }, - { updateTextFieldBorder(isFullScreen) }) - ) - - disallowParentInterceptTouchEvent(views.richTextComposerEditText) - disallowParentInterceptTouchEvent(views.plainTextComposerEditText) - - views.composerModeCloseView.setOnClickListener { - callback?.onCloseRelatedMessage() - } - - views.sendButton.setOnClickListener { - val textMessage = - views.richTextComposerEditText.getMarkdown() // text?.toSpannable() ?: "" - callback?.onSendMessage(textMessage) - } - - views.attachmentButton.setOnClickListener { - callback?.onAddAttachment() - } - - views.composerFullScreenButton.apply { - updateFullScreenButtonVisibility() - setOnClickListener { - callback?.onFullScreenModeChanged() - } - } - - views.composerEditTextOuterBorder.background = borderShapeDrawable - - setupRichTextMenu() - - updateTextFieldBorder(isFullScreen) - } - - private fun setupRichTextMenu() { - addRichTextMenuItem( - R.drawable.ic_composer_bold, - R.string.rich_text_editor_format_bold, - ComposerAction.BOLD - ) { - views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold) - } - addRichTextMenuItem( - R.drawable.ic_composer_italic, - R.string.rich_text_editor_format_italic, - ComposerAction.ITALIC - ) { - views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic) - } - addRichTextMenuItem( - R.drawable.ic_composer_underlined, - R.string.rich_text_editor_format_underline, - ComposerAction.UNDERLINE - ) { - views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline) - } - addRichTextMenuItem( - R.drawable.ic_composer_strikethrough, - R.string.rich_text_editor_format_strikethrough, - ComposerAction.STRIKE_THROUGH - ) { - views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun disallowParentInterceptTouchEvent(view: View) { - view.setOnTouchListener { v, event -> - if (v.hasFocus()) { - v.parent?.requestDisallowInterceptTouchEvent(true) - val action = event.actionMasked - if (action == MotionEvent.ACTION_SCROLL) { - v.parent?.requestDisallowInterceptTouchEvent(false) - return@setOnTouchListener true - } - } - false - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - - views.richTextComposerEditText.actionStatesChangedListener = - EditorEditText.OnActionStatesChangedListener { state -> - for (action in state.keys) { - updateMenuStateFor(action, state) - } - } - - updateEditTextVisibility() - } - - private fun updateEditTextVisibility() { - views.richTextComposerEditText.isVisible = isTextFormattingEnabled - views.richTextMenu.isVisible = isTextFormattingEnabled - views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled - - // The layouts for formatted text mode and plain text mode are different, so we need to update the constraints - val dpToPx = { dp: Int -> dimensionConverter.dpToPx(dp) } - ConstraintSet().apply { - clone(views.composerLayoutContent) - clear(R.id.composerEditTextOuterBorder, ConstraintSet.TOP) - clear(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM) - clear(R.id.composerEditTextOuterBorder, ConstraintSet.START) - clear(R.id.composerEditTextOuterBorder, ConstraintSet.END) - if (isTextFormattingEnabled) { - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.TOP, - R.id.composerLayoutContent, - ConstraintSet.TOP, - dpToPx(8) - ) - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.BOTTOM, - R.id.sendButton, - ConstraintSet.TOP, - 0 - ) - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.START, - R.id.composerLayoutContent, - ConstraintSet.START, - dpToPx(12) - ) - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.END, - R.id.composerLayoutContent, - ConstraintSet.END, - dpToPx(12) - ) - } else { - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.TOP, - R.id.composerLayoutContent, - ConstraintSet.TOP, - dpToPx(10) - ) - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.BOTTOM, - R.id.composerLayoutContent, - ConstraintSet.BOTTOM, - dpToPx(10) - ) - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.START, - R.id.attachmentButton, - ConstraintSet.END, - 0 - ) - connect( - R.id.composerEditTextOuterBorder, - ConstraintSet.END, - R.id.sendButton, - ConstraintSet.START, - 0 - ) - } - applyTo(views.composerLayoutContent) - } - } - - private fun updateFullScreenButtonVisibility() { - val isLargeScreenDevice = - resources.configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) - val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - // There's no point in having full screen in landscape since there's almost no vertical space - views.composerFullScreenButton.isInvisible = - !isTextFormattingEnabled || (isLandscape && !isLargeScreenDevice) - } - - /** - * Updates the non-active input with the contents of the active input. - */ - private fun syncEditTexts() = - if (isTextFormattingEnabled) { - views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown()) - } else { - views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString()) - } - - private fun addRichTextMenuItem( - @DrawableRes iconId: Int, - @StringRes description: Int, - action: ComposerAction, - onClick: () -> Unit - ) { - val inflater = LayoutInflater.from(context) - val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) - button.root.tag = action - with(button.root) { - contentDescription = resources.getString(description) - setImageResource(iconId) - setOnClickListener { - onClick() - } - } - } - - private fun updateMenuStateFor( - action: ComposerAction, - menuState: Map - ) { - val button = findViewWithTag(action) ?: return - val stateForAction = menuState[action] - button.isEnabled = stateForAction != ActionState.DISABLED - button.isSelected = stateForAction == ActionState.REVERSED - } - - fun estimateCollapsedHeight(): Int { - val editText = this.editText - val originalLines = editText.maxLines - val originalParamsHeight = editText.layoutParams.height - editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED - editText.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.UNSPECIFIED, - ) - val result = measuredHeight - editText.layoutParams.height = originalParamsHeight - editText.maxLines = originalLines - return result - } - - private fun updateTextFieldBorder(isFullScreen: Boolean) { - val isMultiline = - editText.editableText.lines().count() > 1 || isFullScreen || hasRelatedMessage - val cornerSize = if (isMultiline) { - resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) - } else { - resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) - }.toFloat() - borderShapeDrawable.setCornerSize(cornerSize) - } - - private fun replaceFormattedContent(text: CharSequence) { - views.richTextComposerEditText.setHtml(text.toString()) - updateTextFieldBorder(isFullScreen) - } - - override fun setTextIfDifferent(text: CharSequence?): Boolean { - val result = editText.setTextIfDifferent(text) - updateTextFieldBorder(isFullScreen) - return result - } - - private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { - if (isFullScreen) { - editText.maxLines = Int.MAX_VALUE - } else { - editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED - } - } - - override fun renderComposerMode(mode: MessageComposerMode) { - if (this.composerMode == mode) return - this.composerMode = mode - - if (mode is MessageComposerMode.Special) { - views.composerModeGroup.isVisible = true - replaceFormattedContent(mode.defaultContent) - hasRelatedMessage = true - editText.showKeyboard(andRequestFocus = true) - } else { - views.composerModeGroup.isGone = true - (mode as? MessageComposerMode.Normal)?.content?.let { text -> - // TODO un-comment once we update to a version of the lib > 0.8.0 - /* - if (isTextFormattingEnabled) { - replaceFormattedContent(text) - } else { - views.plainTextComposerEditText.setText(text) - } - */ - views.plainTextComposerEditText.setText(text) - } - views.sendButton.contentDescription = resources.getString(StringR.string.action_send) - hasRelatedMessage = false - } - - views.sendButton.apply { - if (mode is MessageComposerMode.Edit) { - contentDescription = resources.getString(StringR.string.action_save) - setImageResource(R.drawable.ic_composer_rich_text_save) - } else { - contentDescription = resources.getString(StringR.string.action_send) - setImageResource(R.drawable.ic_rich_composer_send) - } - } - - updateTextFieldBorder(isFullScreen) - - when (mode) { - is MessageComposerMode.Edit -> { - views.composerModeTitleView.setText(R.string.editing) - views.composerModeIconView.setImageResource(R.drawable.ic_composer_rich_text_editor_edit) - } - is MessageComposerMode.Quote -> { - views.composerModeTitleView.setText(R.string.quoting) - views.composerModeIconView.setImageResource(R.drawable.ic_quote) - } - is MessageComposerMode.Reply -> { - val userName = mode.senderName - views.composerModeTitleView.text = - resources.getString(R.string.replying_to, userName) - views.composerModeIconView.setImageResource(R.drawable.ic_reply) - } - else -> Unit - } - } - - private class TextChangeListener( - private val onTextChanged: (s: Editable) -> Unit, - private val onExpandedChanged: (isExpanded: Boolean) -> Unit, - ) : TextWatcher { - private var previousTextWasExpanded = false - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable) { - onTextChanged.invoke(s) - - val isExpanded = s.lines().count() > 1 - if (previousTextWasExpanded != isExpanded) { - onExpandedChanged(isExpanded) - } - previousTextWasExpanded = isExpanded - } - } -} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 1ede7be8fa..92bcff5000 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -16,140 +16,275 @@ package io.element.android.libraries.textcomposer -import android.graphics.Color -import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.view.isInvisible -import androidx.core.view.isVisible +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.modifiers.applyIf import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.LocalColors +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.ui.strings.R as StringR +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TextComposer( - fullscreen: Boolean, composerText: String?, composerMode: MessageComposerMode, composerCanSendMessage: Boolean, - isInDarkMode: Boolean, modifier: Modifier = Modifier, + focusRequester: FocusRequester = FocusRequester(), onSendMessage: (String) -> Unit = {}, - onFullscreenToggle: () -> Unit = {}, - onCloseSpecialMode: () -> Unit = {}, + onResetComposerMode: () -> Unit = {}, onComposerTextChange: (CharSequence) -> Unit = {}, onAddAttachment:() -> Unit = {}, ) { - if (LocalInspectionMode.current) { - FakeComposer(modifier) - } else { - val focusRequester = FocusRequester() - AndroidView( - modifier = modifier.focusRequester(focusRequester), - factory = { context -> - RichTextComposerLayout(context).apply { - // Sets up listeners for View -> Compose communication - this.callback = object : Callback { - override fun onRichContentSelected(contentUri: Uri): Boolean { - return false - } - - override fun onTextChanged(text: CharSequence) { - onComposerTextChange(text) - } - - override fun onCloseRelatedMessage() { - onCloseSpecialMode() - } - - override fun onSendMessage(text: CharSequence) { - // text contains markdown. - onSendMessage(text.toString()) - } - - override fun onAddAttachment() { - onAddAttachment() - } - - override fun onExpandOrCompactChange() { - } - - override fun onFullScreenModeChanged() { - onFullscreenToggle() - } - } - setFullScreen(fullscreen, animated = false, manageKeyboard = true) - (this as MessageComposerView).apply { - setup(isInDarkMode, composerMode) - } - } - }, - update = { view -> - // View's been inflated or state read in this block has been updated - // Add logic here if necessary - - // As selectedItem is read here, AndroidView will recompose - // whenever the state changes - // Example of Compose -> View communication - val messageComposerView = (view as MessageComposerView) - view.setFullScreen(fullscreen, animated = false, manageKeyboard = false) - messageComposerView.renderComposerMode(composerMode) - messageComposerView.sendButton.isInvisible = !composerCanSendMessage - messageComposerView.setTextIfDifferent(composerText ?: "") - messageComposerView.editText.requestFocus() + val text = composerText.orEmpty() + Row(modifier.padding( + horizontal = 12.dp, + vertical = 8.dp + ), verticalAlignment = Alignment.Bottom) { + AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp)) + Spacer(modifier = Modifier.width(12.dp)) + var lineCount by remember { mutableStateOf(0) } + val roundedCorners = remember(lineCount, composerMode) { + if (lineCount > 1 || composerMode is MessageComposerMode.Special) { + RoundedCornerShape(20.dp) + } else { + RoundedCornerShape(28.dp) } - ) + } + + val minHeight = 42.dp + Column( + modifier = Modifier + .fillMaxWidth() + .clip(roundedCorners) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, roundedCorners) + ) { + if (composerMode is MessageComposerMode.Special) { + ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) + } + val defaultTypography = ElementTextStyles.Regular.callout.copy(textAlign = TextAlign.Start) + Box { + BasicTextField( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .focusRequester(focusRequester), + value = text, + onValueChange = { onComposerTextChange(it) }, + onTextLayout = { + lineCount = it.lineCount + }, + textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary), + cursorBrush = SolidColor(LocalColors.current.accentColor), + decorationBox = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = text, + innerTextField = innerTextField, + enabled = true, + singleLine = false, + visualTransformation = VisualTransformation.None, + shape = roundedCorners, + contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 42.dp), + interactionSource = remember { MutableInteractionSource() }, + placeholder = { + Text(stringResource(StringR.string.common_message), style = defaultTypography) + }, + colors = TextFieldDefaults.colors( + unfocusedTextColor = MaterialTheme.colorScheme.secondary, + focusedTextColor = MaterialTheme.colorScheme.primary, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.secondary, + focusedPlaceholderColor = MaterialTheme.colorScheme.secondary, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + errorContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ) + ) + } + ) + + SendButton( + text = text, + canSendMessage = composerCanSendMessage, + onSendMessage = onSendMessage, + composerMode = composerMode, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + ) + } + } } } @Composable -private fun FakeComposer( +private fun ComposerModeView( + composerMode: MessageComposerMode, + onResetComposerMode: () -> Unit, modifier: Modifier = Modifier, ) { - // AndroidView is not Available in this mode, just render a Text - Box( - modifier = modifier - .fillMaxWidth() - .height(80.dp) - ) { - Text( - modifier = Modifier - .align(Alignment.Center), - textAlign = TextAlign.Center, - text = "Composer Preview", - fontSize = 20.sp, - color = MaterialTheme.colorScheme.secondary, - ) + when (composerMode) { + is MessageComposerMode.Edit -> { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) { + Icon( + resourceId = VectorIcons.Edit, + contentDescription = stringResource(R.string.editing), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp), + ) + Text( + stringResource(R.string.editing), + style = ElementTextStyles.Regular.caption2, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(StringR.string.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false) + ), + + ) + } + } + else -> Unit } } -private fun MessageComposerView.setup(isDarkMode: Boolean, composerMode: MessageComposerMode) { - val editTextColor = if (isDarkMode) { - Color.WHITE - } else { - Color.BLACK +@Composable +private fun AttachmentButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier) { + Surface( + Modifier + .size(30.dp) + .clickable(true, onClick = onClick), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary + ) { + Image( + modifier = Modifier.size(12.5f.dp), + painter = painterResource(R.drawable.ic_add_attachment), + contentDescription = null, + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint( + LocalContentColor.current + ) + ) + } + } +} + +@Composable +private fun BoxScope.SendButton( + text: String, + canSendMessage: Boolean, + onSendMessage: (String) -> Unit, + composerMode: MessageComposerMode, + modifier: Modifier = Modifier, +) { + val interactionSource = MutableInteractionSource() + Box( + modifier = modifier + .clip(CircleShape) + .background(if (canSendMessage) LocalColors.current.accentColor else Color.Transparent) + .size(30.dp) + .align(Alignment.BottomEnd) + .applyIf(composerMode !is MessageComposerMode.Edit, ifTrue = { + padding(start = 1.dp) // Center the arrow in the circle + }) + .clickable( + enabled = canSendMessage, + interactionSource = interactionSource, + indication = rememberRipple(bounded = false), + onClick = { + onSendMessage(text) + }), + contentAlignment = Alignment.Center, + ) { + val iconId = when (composerMode) { + is MessageComposerMode.Edit -> R.drawable.ic_tick + else -> R.drawable.ic_send + } + val contentDescription = when (composerMode) { + is MessageComposerMode.Edit -> stringResource(StringR.string.action_edit) + else -> stringResource(StringR.string.action_send) + } + Icon( + modifier = Modifier.size(16.dp), + resourceId = iconId, + contentDescription = contentDescription, + tint = if (canSendMessage) Color.White else LocalColors.current.quaternary + ) } - editText.setTextColor(editTextColor) - editText.setHintTextColor(editTextColor) - editText.setHint(R.string.rich_text_editor_composer_placeholder) - emojiButton?.isVisible = true - sendButton.isVisible = true - editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED - renderComposerMode(composerMode) } @Preview @@ -162,15 +297,38 @@ internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview() @Composable private fun ContentToPreview() { - TextComposer( - onSendMessage = {}, - fullscreen = false, - onFullscreenToggle = { }, - onComposerTextChange = {}, - composerMode = MessageComposerMode.Normal(""), - onCloseSpecialMode = {}, - composerCanSendMessage = true, - composerText = "Message", - isInDarkMode = true, - ) + Column { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + composerCanSendMessage = false, + composerText = "", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text"), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + } } diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/EditTextExtensions.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/EditTextExtensions.kt deleted file mode 100644 index 03d501f258..0000000000 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/EditTextExtensions.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.textcomposer.tools - -import android.text.Spanned -import android.widget.EditText - -fun EditText.setTextIfDifferent(newText: CharSequence?): Boolean { - if (!isTextDifferent(newText, text)) { - // Previous text is the same. No op - return false - } - setText(newText) - // Since the text changed we move the cursor to the end of the new text. - // This allows us to fill in text programmatically with a different value, - // but if the user is typing and the view is rebound we won't lose their cursor position. - setSelection(newText?.length ?: 0) - return true -} - -private fun isTextDifferent(str1: CharSequence?, str2: CharSequence?): Boolean { - if (str1 === str2) { - return false - } - if (str1 == null || str2 == null) { - return true - } - val length = str1.length - if (length != str2.length) { - return true - } - if (str1 is Spanned) { - return str1 != str2 - } - for (i in 0 until length) { - if (str1[i] != str2[i]) { - return true - } - } - return false -} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/SimpleTransitionListener.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/SimpleTransitionListener.kt deleted file mode 100644 index 0aab1bdb6a..0000000000 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/SimpleTransitionListener.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.textcomposer.tools - -import androidx.transition.Transition - -open class SimpleTransitionListener : Transition.TransitionListener { - override fun onTransitionEnd(transition: Transition) { - // No op - } - - override fun onTransitionResume(transition: Transition) { - // No op - } - - override fun onTransitionPause(transition: Transition) { - // No op - } - - override fun onTransitionCancel(transition: Transition) { - // No op - } - - override fun onTransitionStart(transition: Transition) { - // No op - } -} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/ViewExtensions.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/ViewExtensions.kt deleted file mode 100644 index 8f1a1aacca..0000000000 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/tools/ViewExtensions.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.textcomposer.tools - -import android.view.ViewGroup -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet - -fun ViewGroup.animateLayoutChange(animationDuration: Long, transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - }) - } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) -} diff --git a/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml b/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml new file mode 100644 index 0000000000..ac9d53639b --- /dev/null +++ b/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/textcomposer/src/main/res/drawable/ic_send.xml b/libraries/textcomposer/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000000..64e0f120c4 --- /dev/null +++ b/libraries/textcomposer/src/main/res/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/textcomposer/src/main/res/drawable/ic_tick.xml b/libraries/textcomposer/src/main/res/drawable/ic_tick.xml new file mode 100644 index 0000000000..cf1d71a56f --- /dev/null +++ b/libraries/textcomposer/src/main/res/drawable/ic_tick.xml @@ -0,0 +1,9 @@ + + + diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 0a1b93acde..fa601a998d 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -98,6 +98,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:usersearch:impl")) + implementation(project(":libraries:textcomposer")) } fun DependencyHandlerScope.allServicesImpl() { diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 832dea6b88..3be64ba72c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a48ccf1ff3570915b0673de7c0367d951b795c833d7896645b37000b8dc09c15 -size 9649 +oid sha256:8c88bfa85e91e6ab247e3feec199c8a29233827dbd2aad28cf9ff00ea9b99117 +size 10927 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 76f3a4c6f7..3d9b36339c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:945b360a62877d16ec7c25942158ae47e244df85118ca861482ff3bbf0aef55f -size 9268 +oid sha256:36adfe10149d33ce817d520d3f542b5441713f7ceddc5864747f624f5e26d432 +size 10620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index f25169f387..140ae5fece 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce6cbcf47c5e76d72fd9bf15e5676510ac29a3087cc862c67ccd4f7de1f24359 -size 45546 +oid sha256:7fbb93faeae669493b42bc5831b1c5bb67b25ed688d582e01249a9e23e03425a +size 47340 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 0b5394c953..eefb66d667 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9af27bab7bbac9914f4e2694f1ea9c200c57edb345bd107cfb5c2edb48cd056e -size 45725 +oid sha256:e5e5ac66335825c321666fe9564d1dbbd00ebbfe67f39d89ac133fcbd2b36b70 +size 47473 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index f25169f387..1c9328d3b7 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce6cbcf47c5e76d72fd9bf15e5676510ac29a3087cc862c67ccd4f7de1f24359 -size 45546 +oid sha256:72bc2d1171ca0d80bfc4522bbbb15c5973dbae362351c344f9b25382da4831d0 +size 47701 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 2fba27be05..c853807cec 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99e4c0fe8076952a490ab58807913984a9a6598b589c5e35bc9eececc741bf11 -size 44666 +oid sha256:603a9bc343feceb5c1f86a36dcfdca7edf71a150237419a16895d12247b032d2 +size 46271 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 44e3aa48c1..0fded57a8e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46660a369ee182afbf49c5d8022fa79c4a358aee7e2aea83bdf08f66a16b5ff1 -size 45095 +oid sha256:b06f5cdf95f2850a488e71c24c9857eca7b62d147b46b77b3c2ffece75789491 +size 46682 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 2fba27be05..1fb686f4d4 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99e4c0fe8076952a490ab58807913984a9a6598b589c5e35bc9eececc741bf11 -size 44666 +oid sha256:b4a13a0332f1d5ce24824434cb909de2d2bf4af601e6924155d2a550d9e03ec0 +size 46634 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..72f00d3be0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7441f73e8567bbac360867f9b860621ec4766a67d5295d04cda45a09f942d0b5 +size 47865 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4cb8b441f3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f44fe7919578afe43e77b414f294e2ae7f1761c9528feeab548303428bfeba43 +size 46117