Update voice message recording button behaviour (#1784)

Changes recording button behaviour so that
- tapping the record button starts a recording and displays the stop button
- tapping the stop button stops the recording
- tapping the delete button cancels the recording
- 'hold to record' tooltip is removed


---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-11-14 14:05:59 +00:00 committed by GitHub
parent ce75f7465d
commit 53f03335c3
37 changed files with 221 additions and 576 deletions

View file

@ -66,7 +66,7 @@ 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.VoiceMessageRecorderButton
import io.element.android.libraries.textcomposer.components.SendButton
import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton
@ -75,7 +75,7 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin
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.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
@ -103,7 +103,7 @@ fun TextComposer(
onResetComposerMode: () -> Unit = {},
onAddAttachment: () -> Unit = {},
onDismissTextFormatting: () -> Unit = {},
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit = {},
onVoicePlayerEvent: (VoiceMessagePlayerEvent) -> Unit = {},
onSendVoiceMessage: () -> Unit = {},
onDeleteVoiceMessage: () -> Unit = {},
@ -167,16 +167,15 @@ fun TextComposer(
)
}
val recordVoiceButton = @Composable {
RecordButton(
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
VoiceMessageRecorderButton(
isRecording = voiceMessageState is VoiceMessageState.Recording,
onEvent = onVoiceRecorderEvent,
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = { onSendVoiceMessage() },
onClick = onSendVoiceMessage,
composerMode = composerMode,
)
}
@ -223,8 +222,12 @@ fun TextComposer(
}
val voiceDeleteButton = @Composable {
if (voiceMessageState is VoiceMessageState.Preview) {
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) })
else -> {}
}
}
@ -286,7 +289,7 @@ private fun StandardLayout(
verticalAlignment = Alignment.Bottom,
) {
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
if (voiceMessageState is VoiceMessageState.Preview) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)

View file

@ -1,189 +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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.tooltip.ElementTooltipDefaults
import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip
import io.element.android.libraries.designsystem.components.tooltip.TooltipBox
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.R
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun RecordButton(
modifier: Modifier = Modifier,
initialTooltipIsVisible: Boolean = false,
onPressStart: () -> Unit = {},
onLongPressEnd: () -> Unit = {},
onTap: () -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val pressState = rememberPressState()
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val tooltipState = rememberTooltipState(
initialIsVisible = initialTooltipIsVisible
)
PressStateEffects(
pressState = pressState.value,
onPressStart = {
onPressStart()
performHapticFeedback()
},
onLongPressEnd = {
onLongPressEnd()
performHapticFeedback()
},
onTap = {
onTap()
performHapticFeedback()
coroutineScope.launch { tooltipState.show() }
},
)
Box(modifier = modifier) {
HoldToRecordTooltip(
tooltipState = tooltipState,
spacingBetweenTooltipAndAnchor = 0.dp, // Accounts for the 48.dp size of the record button
anchor = {
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),
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,
)
}
}
@Composable
private fun HoldToRecordTooltip(
tooltipState: TooltipState,
spacingBetweenTooltipAndAnchor: Dp,
modifier: Modifier = Modifier,
anchor: @Composable () -> Unit,
) {
TooltipBox(
positionProvider = ElementTooltipDefaults.rememberPlainTooltipPositionProvider(
spacingBetweenTooltipAndAnchor = spacingBetweenTooltipAndAnchor,
),
tooltip = {
PlainTooltip {
Text(
text = stringResource(R.string.screen_room_voice_message_tooltip),
color = ElementTheme.colors.textOnSolidPrimary,
style = ElementTheme.typography.fontBodySmMedium,
)
}
},
state = tooltipState,
modifier = modifier,
focusable = false,
enableUserInput = false,
content = anchor,
)
}
@PreviewsDayNight
@Composable
internal fun RecordButtonPreview() = ElementPreview {
Row {
RecordButtonView(isPressed = false)
RecordButtonView(isPressed = true)
}
}
@PreviewsDayNight
@Composable
internal fun HoldToRecordTooltipPreview() = ElementPreview {
Box(modifier = Modifier.fillMaxSize()) {
RecordButton(
modifier = Modifier.align(Alignment.BottomEnd),
initialTooltipIsVisible = true,
)
}
}

View file

@ -43,7 +43,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.textcomposer.R
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.formatShort
@ -145,14 +145,14 @@ private fun PlayerButton(
@Composable
private fun PauseIcon() = Icon(
resourceId = R.drawable.ic_pause,
resourceId = CommonDrawables.ic_pause,
contentDescription = stringResource(id = CommonStrings.a11y_pause),
modifier = Modifier.size(20.dp),
)
@Composable
private fun PlayIcon() = Icon(
resourceId = R.drawable.ic_play,
resourceId = CommonDrawables.ic_play,
contentDescription = stringResource(id = CommonStrings.a11y_play),
modifier = Modifier.size(20.dp),
)

View file

@ -0,0 +1,119 @@
/*
* 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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
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.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.model.VoiceMessageRecorderEvent
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun VoiceMessageRecorderButton(
isRecording: Boolean,
modifier: Modifier = Modifier,
onEvent: (VoiceMessageRecorderEvent) -> Unit = {},
) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) {
StopButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
} else {
StartButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
}
}
@Composable
private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_compound_mic_on_outline,
contentDescription = stringResource(CommonStrings.a11y_voice_message_record),
tint = ElementTheme.colors.iconSecondary,
)
}
@Composable
private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) {
Box(
Modifier
.size(36.dp)
.background(
color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape,
)
)
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
contentDescription = stringResource(CommonStrings.a11y_voice_message_stop_recording),
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
Row {
VoiceMessageRecorderButton(isRecording = false)
VoiceMessageRecorderButton(isRecording = true)
}
}

View file

@ -16,8 +16,8 @@
package io.element.android.libraries.textcomposer.model
sealed interface PressEvent {
data object PressStart: PressEvent
data object Tapped: PressEvent
data object LongPressEnd: PressEvent
sealed interface VoiceMessageRecorderEvent {
data object Start: VoiceMessageRecorderEvent
data object Stop: VoiceMessageRecorderEvent
data object Cancel: VoiceMessageRecorderEvent
}

View file

@ -1,31 +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.utils
/**
* State of a press gesture.
*/
internal sealed interface PressState {
data class Idle(
val lastPress: Pressing?
) : PressState
sealed interface Pressing : PressState
data object Tapping : Pressing
data object LongPressing : Pressing
}

View file

@ -1,47 +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.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

@ -1,101 +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.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

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M13.25,15.25C12.837,15.25 12.484,15.103 12.191,14.809C11.897,14.516 11.75,14.163 11.75,13.75V6.25C11.75,5.838 11.897,5.484 12.191,5.191C12.484,4.897 12.837,4.75 13.25,4.75H13.75C14.163,4.75 14.516,4.897 14.809,5.191C15.103,5.484 15.25,5.838 15.25,6.25V13.75C15.25,14.163 15.103,14.516 14.809,14.809C14.516,15.103 14.163,15.25 13.75,15.25H13.25ZM6.25,15.25C5.838,15.25 5.484,15.103 5.191,14.809C4.897,14.516 4.75,14.163 4.75,13.75V6.25C4.75,5.838 4.897,5.484 5.191,5.191C5.484,4.897 5.838,4.75 6.25,4.75H6.75C7.162,4.75 7.516,4.897 7.809,5.191C8.103,5.484 8.25,5.838 8.25,6.25V13.75C8.25,14.163 8.103,14.516 7.809,14.809C7.516,15.103 7.162,15.25 6.75,15.25H6.25Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M8.042,14.958C7.75,15.153 7.451,15.167 7.146,15C6.84,14.833 6.688,14.569 6.688,14.208V5.75C6.688,5.389 6.84,5.125 7.146,4.958C7.451,4.792 7.75,4.806 8.042,5L14.688,9.25C14.965,9.417 15.104,9.66 15.104,9.979C15.104,10.299 14.965,10.542 14.688,10.708L8.042,14.958Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -1,111 +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.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,
)
}