[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 <benoitm+elementbot@element.io>
This commit is contained in:
committed by
GitHub
parent
759abf1565
commit
595fbda220
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
changelog.d/484.feature
Normal file
1
changelog.d/484.feature
Normal file
@@ -0,0 +1 @@
|
||||
New UI for composer and editing messages
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() =
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<ViewGroup.LayoutParams> {
|
||||
height =
|
||||
if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
editText.updateLayoutParams<ViewGroup.LayoutParams> {
|
||||
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<ComposerAction, ActionState>
|
||||
) {
|
||||
val button = findViewWithTag<ImageButton>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,773Q457.91,773 441.46,756.54Q425,740.09 425,718.31L425,535L241.69,535Q219.91,535 203.46,518.54Q187,502.09 187,480Q187,457.91 203.46,441.46Q219.91,425 241.69,425L425,425L425,241.69Q425,219.91 441.46,203.46Q457.91,187 480,187Q502.09,187 518.54,203.46Q535,219.91 535,241.69L535,425L718.31,425Q740.09,425 756.54,441.46Q773,457.91 773,480Q773,502.09 756.54,518.54Q740.09,535 718.31,535L535,535L535,718.31Q535,740.09 518.54,756.54Q502.09,773 480,773Z"/>
|
||||
</vector>
|
||||
9
libraries/textcomposer/src/main/res/drawable/ic_send.xml
Normal file
9
libraries/textcomposer/src/main/res/drawable/ic_send.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:pathData="M15.404,8.965L1.563,15.882C0.631,16.348 -0.34,15.348 0.116,14.435C0.116,14.435 1.832,10.971 2.303,10.064C2.775,9.156 3.315,8.999 8.331,8.351C8.517,8.327 8.669,8.187 8.669,8C8.669,7.813 8.517,7.673 8.331,7.649C3.315,7.001 2.775,6.844 2.303,5.936C1.832,5.029 0.116,1.565 0.116,1.565C-0.34,0.653 0.631,-0.348 1.563,0.118L15.404,7.036C16.199,7.433 16.199,8.567 15.404,8.965Z"
|
||||
android:fillColor="#A6ADB7"/>
|
||||
</vector>
|
||||
9
libraries/textcomposer/src/main/res/drawable/ic_tick.xml
Normal file
9
libraries/textcomposer/src/main/res/drawable/ic_tick.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="15dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="15">
|
||||
<path
|
||||
android:pathData="M6.518,14.779C6.953,14.779 7.297,14.597 7.535,14.233L15.403,1.968C15.579,1.692 15.65,1.461 15.65,1.234C15.65,0.657 15.245,0.26 14.662,0.26C14.249,0.26 14.009,0.399 13.759,0.792L6.484,12.348L2.736,7.529C2.492,7.205 2.236,7.07 1.874,7.07C1.277,7.07 0.857,7.489 0.857,8.066C0.857,8.315 0.95,8.565 1.158,8.819L5.495,14.245C5.784,14.606 6.096,14.779 6.518,14.779Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
@@ -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() {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user