[Voice messages] Add voice recording UI (#1546)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-10-12 16:17:18 +01:00 committed by GitHub
parent 7f65c137af
commit 12404fab78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
293 changed files with 967 additions and 52 deletions

View file

@ -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,
)
})
)

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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

View file

@ -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(

View file

@ -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?,

View file

@ -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

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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()
}
}
}

View file

@ -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")
}
}
}

View file

@ -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,
)
}