[Voice messages] Add voice recording UI (#1546)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
7f65c137af
commit
12404fab78
293 changed files with 967 additions and 52 deletions
|
|
@ -37,6 +37,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -45,8 +47,8 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
|
@ -61,9 +63,15 @@ import io.element.android.libraries.testtags.TestTags
|
|||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
||||
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
|
||||
import io.element.android.libraries.textcomposer.components.RecordButton
|
||||
import io.element.android.libraries.textcomposer.components.RecordingProgress
|
||||
import io.element.android.libraries.textcomposer.components.SendButton
|
||||
import io.element.android.libraries.textcomposer.components.TextFormatting
|
||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.RichTextEditor
|
||||
|
|
@ -74,8 +82,10 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
@Composable
|
||||
fun TextComposer(
|
||||
state: RichTextEditorState,
|
||||
voiceMessageState: VoiceMessageState,
|
||||
composerMode: MessageComposerMode,
|
||||
enableTextFormatting: Boolean,
|
||||
enableVoiceMessages: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showTextFormatting: Boolean = false,
|
||||
subcomposing: Boolean = false,
|
||||
|
|
@ -84,6 +94,7 @@ fun TextComposer(
|
|||
onResetComposerMode: () -> Unit = {},
|
||||
onAddAttachment: () -> Unit = {},
|
||||
onDismissTextFormatting: () -> Unit = {},
|
||||
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
) {
|
||||
val onSendClicked = {
|
||||
|
|
@ -118,16 +129,34 @@ fun TextComposer(
|
|||
)
|
||||
}
|
||||
|
||||
val canSendMessage by remember { derivedStateOf { state.messageHtml.isNotEmpty() } }
|
||||
val sendButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = state.messageHtml.isNotEmpty(),
|
||||
canSendMessage = canSendMessage,
|
||||
onClick = onSendClicked,
|
||||
composerMode = composerMode,
|
||||
)
|
||||
}
|
||||
val recordButton = @Composable {
|
||||
RecordButton(
|
||||
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
|
||||
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
|
||||
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
|
||||
)
|
||||
}
|
||||
|
||||
val textFormattingOptions = @Composable { TextFormatting(state = state) }
|
||||
|
||||
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
|
||||
sendButton
|
||||
} else {
|
||||
recordButton
|
||||
}
|
||||
|
||||
val recordingProgress = @Composable {
|
||||
RecordingProgress()
|
||||
}
|
||||
|
||||
if (showTextFormatting) {
|
||||
TextFormattingLayout(
|
||||
modifier = layoutModifier,
|
||||
|
|
@ -136,14 +165,16 @@ fun TextComposer(
|
|||
DismissTextFormattingButton(onClick = onDismissTextFormatting)
|
||||
},
|
||||
textFormatting = textFormattingOptions,
|
||||
sendButton = sendButton
|
||||
sendButton = sendButton,
|
||||
)
|
||||
} else {
|
||||
StandardLayout(
|
||||
voiceMessageState = voiceMessageState,
|
||||
modifier = layoutModifier,
|
||||
composerOptionsButton = composerOptionsButton,
|
||||
textInput = textInput,
|
||||
sendButton = sendButton
|
||||
endButton = sendOrRecordButton,
|
||||
recordingProgress = recordingProgress,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -158,33 +189,45 @@ fun TextComposer(
|
|||
|
||||
@Composable
|
||||
private fun StandardLayout(
|
||||
voiceMessageState: VoiceMessageState,
|
||||
textInput: @Composable () -> Unit,
|
||||
composerOptionsButton: @Composable () -> Unit,
|
||||
sendButton: @Composable () -> Unit,
|
||||
recordingProgress: @Composable () -> Unit,
|
||||
endButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
|
||||
) {
|
||||
composerOptionsButton()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
textInput()
|
||||
if (voiceMessageState is VoiceMessageState.Recording) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
recordingProgress()
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
|
||||
) {
|
||||
composerOptionsButton()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 8.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
textInput()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
||||
) {
|
||||
sendButton()
|
||||
endButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -438,18 +481,22 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
{
|
||||
TextComposer(
|
||||
RichTextEditorState("", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
|
|
@ -457,18 +504,22 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||
initialFocus = true
|
||||
),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message without focus", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
})
|
||||
)
|
||||
|
|
@ -480,23 +531,29 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
|||
PreviewColumn(items = persistentListOf({
|
||||
TextComposer(
|
||||
RichTextEditorState("", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = false),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
showTextFormatting = true,
|
||||
composerMode = MessageComposerMode.Normal(""),
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
|
@ -507,10 +564,12 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
|||
PreviewColumn(items = persistentListOf({
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
|
@ -521,6 +580,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
PreviewColumn(items = persistentListOf({
|
||||
TextComposer(
|
||||
RichTextEditorState(""),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
|
|
@ -533,11 +593,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
},
|
||||
{
|
||||
TextComposer(
|
||||
RichTextEditorState(""),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = true,
|
||||
|
|
@ -550,10 +612,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message"),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = true,
|
||||
|
|
@ -569,10 +633,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message"),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
|
|
@ -588,10 +654,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message"),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
|
|
@ -607,10 +675,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
}, {
|
||||
TextComposer(
|
||||
RichTextEditorState("A message", initialFocus = true),
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
onSendMessage = {},
|
||||
composerMode = MessageComposerMode.Reply(
|
||||
isThreaded = false,
|
||||
|
|
@ -626,6 +696,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
|||
),
|
||||
onResetComposerMode = {},
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
)
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.textcomposer.utils.PressState
|
||||
import io.element.android.libraries.textcomposer.utils.PressStateEffects
|
||||
import io.element.android.libraries.textcomposer.utils.rememberPressState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun RecordButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onPressStart: () -> Unit = {},
|
||||
onLongPressEnd: () -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pressState = rememberPressState()
|
||||
|
||||
PressStateEffects(
|
||||
pressState = pressState.value,
|
||||
onPressStart = onPressStart,
|
||||
onLongPressEnd = onLongPressEnd,
|
||||
onTap = onTap,
|
||||
)
|
||||
|
||||
RecordButtonView(
|
||||
isPressed = pressState.value is PressState.Pressing,
|
||||
modifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
coroutineScope.launch {
|
||||
when (event.type) {
|
||||
PointerEventType.Press -> pressState.press()
|
||||
PointerEventType.Release -> pressState.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordButtonView(
|
||||
isPressed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp.applyScaleUp()),
|
||||
resourceId = if (isPressed) {
|
||||
CommonDrawables.ic_compound_mic_on_solid
|
||||
} else {
|
||||
CommonDrawables.ic_compound_mic_on_outline
|
||||
},
|
||||
contentDescription = stringResource(CommonStrings.a11y_voice_message_record),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordButtonPreview() = ElementPreview {
|
||||
Row {
|
||||
RecordButtonView(isPressed = false)
|
||||
RecordButtonView(isPressed = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
internal fun RecordingProgress(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
|
||||
.heightIn(26.dp)
|
||||
|
||||
,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
// TODO Replace with timer UI
|
||||
Text(
|
||||
text = "Recording...", // Not localized because it is a placeholder
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RecordingProgressPreview() {
|
||||
RecordingProgress()
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.text.applyScaleUp
|
|||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
||||
@Composable
|
||||
internal fun textInputRoundedCornerShape(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
data class Message(
|
||||
val html: String?,
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.textcomposer
|
||||
package io.element.android.libraries.textcomposer.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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.model
|
||||
|
||||
sealed class PressEvent {
|
||||
data object PressStart: PressEvent()
|
||||
data object Tapped: PressEvent()
|
||||
data object LongPressEnd: PressEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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.model
|
||||
|
||||
sealed class VoiceMessageState {
|
||||
data object Idle: VoiceMessageState()
|
||||
data object Recording: VoiceMessageState()
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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.utils
|
||||
|
||||
/**
|
||||
* State of a press gesture.
|
||||
*/
|
||||
internal sealed class PressState {
|
||||
data class Idle(
|
||||
val lastPress: Pressing?
|
||||
) : PressState()
|
||||
|
||||
sealed class Pressing : PressState()
|
||||
data object Tapping : Pressing()
|
||||
data object LongPressing : Pressing()
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* React to [PressState] changes.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PressStateEffects(
|
||||
pressState: PressState,
|
||||
onPressStart: () -> Unit = {},
|
||||
onLongPressStart: () -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
onLongPressEnd: () -> Unit = {},
|
||||
) {
|
||||
LaunchedEffect(pressState) {
|
||||
when (pressState) {
|
||||
is PressState.Idle ->
|
||||
when (pressState.lastPress) {
|
||||
PressState.Tapping -> onTap()
|
||||
PressState.LongPressing -> onLongPressEnd()
|
||||
null -> {} // Do nothing
|
||||
}
|
||||
is PressState.LongPressing -> onLongPressStart()
|
||||
PressState.Tapping -> onPressStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
internal fun rememberPressState(
|
||||
longPressTimeoutMillis: Long = LocalViewConfiguration.current.longPressTimeoutMillis,
|
||||
): PressStateHolder {
|
||||
return remember(longPressTimeoutMillis) {
|
||||
PressStateHolder(longPressTimeoutMillis = longPressTimeoutMillis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State machine that keeps track of the pressed state.
|
||||
*
|
||||
* When a press is started, the state will transition through:
|
||||
* [PressState.Idle] -> [PressState.Tapping] -> ...
|
||||
*
|
||||
* If a press is held for a longer time, the state will continue through:
|
||||
* ... -> [PressState.LongPressing] -> ...
|
||||
*
|
||||
* When the press is released the states will then transition back to idle.
|
||||
* ... -> [PressState.Idle]
|
||||
*
|
||||
* Whether a press should be considered a tap or a long press can be determined by
|
||||
* looking at the last press when in the idle state.
|
||||
*
|
||||
* @see [PressStateEffects]
|
||||
* @see [rememberPressState]
|
||||
*/
|
||||
internal class PressStateHolder(
|
||||
private val longPressTimeoutMillis: Long,
|
||||
) : State<PressState> {
|
||||
private var state: PressState by mutableStateOf(PressState.Idle(lastPress = null))
|
||||
|
||||
override val value: PressState
|
||||
get() = state
|
||||
|
||||
private var longPressTimer: Job? = null
|
||||
|
||||
suspend fun press() = coroutineScope {
|
||||
when (state) {
|
||||
is PressState.Idle -> {
|
||||
state = PressState.Tapping
|
||||
}
|
||||
is PressState.Pressing ->
|
||||
Timber.e("Pointer pressed but it has not been released")
|
||||
}
|
||||
|
||||
longPressTimer = launch {
|
||||
delay(longPressTimeoutMillis)
|
||||
yield()
|
||||
|
||||
if (isActive && state == PressState.Tapping) {
|
||||
state = PressState.LongPressing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
longPressTimer?.cancel()
|
||||
longPressTimer = null
|
||||
when (val lastState = state) {
|
||||
is PressState.Pressing ->
|
||||
state = PressState.Idle(lastPress = lastState)
|
||||
is PressState.Idle ->
|
||||
Timber.e("Pointer pressed but it has not been released")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.textcomposer.utils.PressState.Idle
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) class PressStateHolderTest {
|
||||
companion object {
|
||||
const val LONG_PRESS_TIMEOUT_MILLIS = 1L
|
||||
}
|
||||
@Test
|
||||
fun `it starts in idle state`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when press, it moves to tapping state`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release after short delay, it moves through tap states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
stateHolder.release()
|
||||
advanceTimeBy(1.milliseconds) // wait for the long press timeout which should not be triggered
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.Tapping))
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when hold, it moves through long press states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.LongPressing)
|
||||
stateHolder.release()
|
||||
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.LongPressing))
|
||||
press.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release and repress, it doesn't enter long press states`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
val press1 = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
stateHolder.release()
|
||||
val press2 = async { stateHolder.press() }
|
||||
advanceTimeBy(1.milliseconds)
|
||||
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||
press1.await()
|
||||
press2.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when press twice without releasing, it doesn't throw an error`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.press()
|
||||
stateHolder.press()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release without first pressing, it doesn't throw an error`() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.release()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when release twice without pressing, it doesn't throw an error `() = runTest {
|
||||
val stateHolder = createStateHolder()
|
||||
stateHolder.press()
|
||||
stateHolder.release()
|
||||
stateHolder.release()
|
||||
}
|
||||
|
||||
private fun createStateHolder() =
|
||||
PressStateHolder(
|
||||
LONG_PRESS_TIMEOUT_MILLIS,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue