[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:
parent
759abf1565
commit
595fbda220
29 changed files with 425 additions and 877 deletions
|
|
@ -5,8 +5,6 @@ appId: ${APP_ID}
|
||||||
- inputText: ${ROOM_NAME.substring(0, 3)}
|
- inputText: ${ROOM_NAME.substring(0, 3)}
|
||||||
- takeScreenshot: build/maestro/400-SearchRoom
|
- takeScreenshot: build/maestro/400-SearchRoom
|
||||||
- tapOn: ${ROOM_NAME}
|
- tapOn: ${ROOM_NAME}
|
||||||
# Close keyboard
|
|
||||||
- hideKeyboard
|
|
||||||
# Back from timeline
|
# Back from timeline
|
||||||
- back
|
- back
|
||||||
# Close keyboard
|
# Close keyboard
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@ appId: ${APP_ID}
|
||||||
# TODO Create a room on a new account
|
# TODO Create a room on a new account
|
||||||
- tapOn: ${ROOM_NAME}
|
- tapOn: ${ROOM_NAME}
|
||||||
- takeScreenshot: build/maestro/500-Timeline
|
- takeScreenshot: build/maestro/500-Timeline
|
||||||
- tapOn: "Message…"
|
- tapOn: "Message"
|
||||||
- inputText: "Hello world!"
|
- inputText: "Hello world!"
|
||||||
- tapOn: "Toggle full screen mode"
|
|
||||||
- tapOn: "Toggle full screen mode"
|
|
||||||
- tapOn: "Send"
|
- tapOn: "Send"
|
||||||
- hideKeyboard
|
- hideKeyboard
|
||||||
- back
|
- 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 androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
|
||||||
import io.element.android.libraries.textcomposer.TextComposer
|
import io.element.android.libraries.textcomposer.TextComposer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -52,17 +51,14 @@ fun MessageComposerView(
|
||||||
|
|
||||||
TextComposer(
|
TextComposer(
|
||||||
onSendMessage = ::sendMessage,
|
onSendMessage = ::sendMessage,
|
||||||
fullscreen = state.isFullScreen,
|
|
||||||
onFullscreenToggle = ::onFullscreenToggle,
|
|
||||||
composerMode = state.mode,
|
composerMode = state.mode,
|
||||||
onCloseSpecialMode = ::onCloseSpecialMode,
|
onResetComposerMode = ::onCloseSpecialMode,
|
||||||
onComposerTextChange = ::onComposerTextChange,
|
onComposerTextChange = ::onComposerTextChange,
|
||||||
onAddAttachment = {
|
onAddAttachment = {
|
||||||
state.eventSink(MessageComposerEvents.AddAttachment)
|
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
},
|
},
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
composerCanSendMessage = state.isSendButtonVisible,
|
||||||
composerText = state.text?.charSequence?.toString(),
|
composerText = state.text?.charSequence?.toString(),
|
||||||
isInDarkMode = !ElementTheme.colors.isLight,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ fun elementColorsDark() = ElementColors(
|
||||||
gray400 = Compound_Gray_400_Dark,
|
gray400 = Compound_Gray_400_Dark,
|
||||||
gray1400 = Compound_Gray_1400_Dark,
|
gray1400 = Compound_Gray_1400_Dark,
|
||||||
textActionCritical = TextColorCriticalDark,
|
textActionCritical = TextColorCriticalDark,
|
||||||
|
accentColor = Color(0xFF0DBD8B),
|
||||||
placeholder = Compound_Gray_800_Dark,
|
placeholder = Compound_Gray_800_Dark,
|
||||||
isLight = false,
|
isLight = false,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ fun elementColorsLight() = ElementColors(
|
||||||
gray400 = Compound_Gray_400_Light,
|
gray400 = Compound_Gray_400_Light,
|
||||||
gray1400 = Compound_Gray_1400_Light,
|
gray1400 = Compound_Gray_1400_Light,
|
||||||
textActionCritical = TextColorCriticalLight,
|
textActionCritical = TextColorCriticalLight,
|
||||||
|
accentColor = Color(0xFF0DBD8B),
|
||||||
placeholder = Compound_Gray_800_Light,
|
placeholder = Compound_Gray_800_Light,
|
||||||
isLight = true,
|
isLight = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class ElementColors(
|
||||||
gray400: Color,
|
gray400: Color,
|
||||||
gray1400: Color,
|
gray1400: Color,
|
||||||
textActionCritical: Color,
|
textActionCritical: Color,
|
||||||
|
accentColor: Color,
|
||||||
placeholder: Color,
|
placeholder: Color,
|
||||||
isLight: Boolean
|
isLight: Boolean
|
||||||
) {
|
) {
|
||||||
|
|
@ -61,6 +62,9 @@ class ElementColors(
|
||||||
var textActionCritical by mutableStateOf(textActionCritical)
|
var textActionCritical by mutableStateOf(textActionCritical)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var accentColor by mutableStateOf(accentColor)
|
||||||
|
private set
|
||||||
|
|
||||||
var placeholder by mutableStateOf(placeholder)
|
var placeholder by mutableStateOf(placeholder)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
@ -77,6 +81,7 @@ class ElementColors(
|
||||||
gray400: Color = this.gray400,
|
gray400: Color = this.gray400,
|
||||||
gray1400: Color = this.gray1400,
|
gray1400: Color = this.gray1400,
|
||||||
textActionCritical: Color = this.textActionCritical,
|
textActionCritical: Color = this.textActionCritical,
|
||||||
|
accentColor: Color = this.accentColor,
|
||||||
placeholder: Color = this.placeholder,
|
placeholder: Color = this.placeholder,
|
||||||
isLight: Boolean = this.isLight,
|
isLight: Boolean = this.isLight,
|
||||||
) = ElementColors(
|
) = ElementColors(
|
||||||
|
|
@ -89,6 +94,7 @@ class ElementColors(
|
||||||
gray400 = gray400,
|
gray400 = gray400,
|
||||||
gray1400 = gray1400,
|
gray1400 = gray1400,
|
||||||
textActionCritical = textActionCritical,
|
textActionCritical = textActionCritical,
|
||||||
|
accentColor = accentColor,
|
||||||
placeholder = placeholder,
|
placeholder = placeholder,
|
||||||
isLight = isLight,
|
isLight = isLight,
|
||||||
)
|
)
|
||||||
|
|
@ -103,6 +109,7 @@ class ElementColors(
|
||||||
gray400 = other.gray400
|
gray400 = other.gray400
|
||||||
gray1400 = other.gray1400
|
gray1400 = other.gray1400
|
||||||
textActionCritical = other.textActionCritical
|
textActionCritical = other.textActionCritical
|
||||||
|
accentColor = other.accentColor
|
||||||
placeholder = other.placeholder
|
placeholder = other.placeholder
|
||||||
isLight = other.isLight
|
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.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event ->
|
fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event ->
|
||||||
if (event.key == Key.Tab || event.key == Key.Enter) {
|
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.LocalAutofill
|
||||||
import androidx.compose.ui.platform.LocalAutofillTree
|
import androidx.compose.ui.platform.LocalAutofillTree
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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)
|
@Preview(group = PreviewGroup.TextFields)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TextFieldLightPreview() =
|
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
|
package io.element.android.libraries.textcomposer
|
||||||
|
|
||||||
import android.graphics.Color
|
import androidx.compose.foundation.Image
|
||||||
import android.net.Uri
|
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.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.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.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import io.element.android.libraries.designsystem.VectorIcons
|
||||||
import androidx.core.view.isInvisible
|
import io.element.android.libraries.designsystem.modifiers.applyIf
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
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.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TextComposer(
|
fun TextComposer(
|
||||||
fullscreen: Boolean,
|
|
||||||
composerText: String?,
|
composerText: String?,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
composerCanSendMessage: Boolean,
|
composerCanSendMessage: Boolean,
|
||||||
isInDarkMode: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
focusRequester: FocusRequester = FocusRequester(),
|
||||||
onSendMessage: (String) -> Unit = {},
|
onSendMessage: (String) -> Unit = {},
|
||||||
onFullscreenToggle: () -> Unit = {},
|
onResetComposerMode: () -> Unit = {},
|
||||||
onCloseSpecialMode: () -> Unit = {},
|
|
||||||
onComposerTextChange: (CharSequence) -> Unit = {},
|
onComposerTextChange: (CharSequence) -> Unit = {},
|
||||||
onAddAttachment:() -> Unit = {},
|
onAddAttachment:() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (LocalInspectionMode.current) {
|
val text = composerText.orEmpty()
|
||||||
FakeComposer(modifier)
|
Row(modifier.padding(
|
||||||
} else {
|
horizontal = 12.dp,
|
||||||
val focusRequester = FocusRequester()
|
vertical = 8.dp
|
||||||
AndroidView(
|
), verticalAlignment = Alignment.Bottom) {
|
||||||
modifier = modifier.focusRequester(focusRequester),
|
AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp))
|
||||||
factory = { context ->
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
RichTextComposerLayout(context).apply {
|
var lineCount by remember { mutableStateOf(0) }
|
||||||
// Sets up listeners for View -> Compose communication
|
val roundedCorners = remember(lineCount, composerMode) {
|
||||||
this.callback = object : Callback {
|
if (lineCount > 1 || composerMode is MessageComposerMode.Special) {
|
||||||
override fun onRichContentSelected(contentUri: Uri): Boolean {
|
RoundedCornerShape(20.dp)
|
||||||
return false
|
} else {
|
||||||
}
|
RoundedCornerShape(28.dp)
|
||||||
|
|
||||||
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 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
|
@Composable
|
||||||
private fun FakeComposer(
|
private fun ComposerModeView(
|
||||||
|
composerMode: MessageComposerMode,
|
||||||
|
onResetComposerMode: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// AndroidView is not Available in this mode, just render a Text
|
when (composerMode) {
|
||||||
Box(
|
is MessageComposerMode.Edit -> {
|
||||||
modifier = modifier
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
.fillMaxWidth()
|
modifier = modifier
|
||||||
.height(80.dp)
|
.fillMaxWidth()
|
||||||
) {
|
.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
Text(
|
Icon(
|
||||||
modifier = Modifier
|
resourceId = VectorIcons.Edit,
|
||||||
.align(Alignment.Center),
|
contentDescription = stringResource(R.string.editing),
|
||||||
textAlign = TextAlign.Center,
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
text = "Composer Preview",
|
modifier = Modifier.size(16.dp),
|
||||||
fontSize = 20.sp,
|
)
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
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) {
|
@Composable
|
||||||
val editTextColor = if (isDarkMode) {
|
private fun AttachmentButton(
|
||||||
Color.WHITE
|
onClick: () -> Unit,
|
||||||
} else {
|
modifier: Modifier = Modifier
|
||||||
Color.BLACK
|
) {
|
||||||
|
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
|
@Preview
|
||||||
|
|
@ -162,15 +297,38 @@ internal fun TextComposerDarkPreview() = ElementPreviewDark { ContentToPreview()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
TextComposer(
|
Column {
|
||||||
onSendMessage = {},
|
TextComposer(
|
||||||
fullscreen = false,
|
onSendMessage = {},
|
||||||
onFullscreenToggle = { },
|
onComposerTextChange = {},
|
||||||
onComposerTextChange = {},
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
onResetComposerMode = {},
|
||||||
onCloseSpecialMode = {},
|
composerCanSendMessage = false,
|
||||||
composerCanSendMessage = true,
|
composerText = "",
|
||||||
composerText = "Message",
|
)
|
||||||
isInDarkMode = true,
|
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:mediapickers:impl"))
|
||||||
implementation(project(":libraries:mediaupload:impl"))
|
implementation(project(":libraries:mediaupload:impl"))
|
||||||
implementation(project(":libraries:usersearch:impl"))
|
implementation(project(":libraries:usersearch:impl"))
|
||||||
|
implementation(project(":libraries:textcomposer"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DependencyHandlerScope.allServicesImpl() {
|
fun DependencyHandlerScope.allServicesImpl() {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a48ccf1ff3570915b0673de7c0367d951b795c833d7896645b37000b8dc09c15
|
oid sha256:8c88bfa85e91e6ab247e3feec199c8a29233827dbd2aad28cf9ff00ea9b99117
|
||||||
size 9649
|
size 10927
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:945b360a62877d16ec7c25942158ae47e244df85118ca861482ff3bbf0aef55f
|
oid sha256:36adfe10149d33ce817d520d3f542b5441713f7ceddc5864747f624f5e26d432
|
||||||
size 9268
|
size 10620
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ce6cbcf47c5e76d72fd9bf15e5676510ac29a3087cc862c67ccd4f7de1f24359
|
oid sha256:7fbb93faeae669493b42bc5831b1c5bb67b25ed688d582e01249a9e23e03425a
|
||||||
size 45546
|
size 47340
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9af27bab7bbac9914f4e2694f1ea9c200c57edb345bd107cfb5c2edb48cd056e
|
oid sha256:e5e5ac66335825c321666fe9564d1dbbd00ebbfe67f39d89ac133fcbd2b36b70
|
||||||
size 45725
|
size 47473
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ce6cbcf47c5e76d72fd9bf15e5676510ac29a3087cc862c67ccd4f7de1f24359
|
oid sha256:72bc2d1171ca0d80bfc4522bbbb15c5973dbae362351c344f9b25382da4831d0
|
||||||
size 45546
|
size 47701
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:99e4c0fe8076952a490ab58807913984a9a6598b589c5e35bc9eececc741bf11
|
oid sha256:603a9bc343feceb5c1f86a36dcfdca7edf71a150237419a16895d12247b032d2
|
||||||
size 44666
|
size 46271
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:46660a369ee182afbf49c5d8022fa79c4a358aee7e2aea83bdf08f66a16b5ff1
|
oid sha256:b06f5cdf95f2850a488e71c24c9857eca7b62d147b46b77b3c2ffece75789491
|
||||||
size 45095
|
size 46682
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:99e4c0fe8076952a490ab58807913984a9a6598b589c5e35bc9eececc741bf11
|
oid sha256:b4a13a0332f1d5ce24824434cb909de2d2bf4af601e6924155d2a550d9e03ec0
|
||||||
size 44666
|
size 46634
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7441f73e8567bbac360867f9b860621ec4766a67d5295d04cda45a09f942d0b5
|
||||||
|
size 47865
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f44fe7919578afe43e77b414f294e2ae7f1761c9528feeab548303428bfeba43
|
||||||
|
size 46117
|
||||||
Loading…
Add table
Add a link
Reference in a new issue