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:
parent
1df3f6113a
commit
0b1d41e861
37 changed files with 221 additions and 576 deletions
1
changelog.d/1784.feature
Normal file
1
changelog.d/1784.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording.
|
||||
|
|
@ -32,7 +32,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
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 kotlinx.coroutines.launch
|
||||
|
|
@ -77,8 +77,8 @@ internal fun MessageComposerView(
|
|||
}
|
||||
}
|
||||
|
||||
val onVoiceRecordButtonEvent = { press: PressEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||
val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecorderEvent(press))
|
||||
}
|
||||
|
||||
val onSendVoiceMessage = {
|
||||
|
|
@ -107,7 +107,7 @@ internal fun MessageComposerView(
|
|||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||
onVoiceRecorderEvent = onVoiceRecorderEvent,
|
||||
onVoicePlayerEvent = onVoicePlayerEvent,
|
||||
onSendVoiceMessage = onSendVoiceMessage,
|
||||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
|
||||
sealed interface VoiceMessageComposerEvents {
|
||||
data class RecordButtonEvent(
|
||||
val pressEvent: PressEvent
|
||||
data class RecorderEvent(
|
||||
val recorderEvent: VoiceMessageRecorderEvent
|
||||
): VoiceMessageComposerEvents
|
||||
data class PlayerEvent(
|
||||
val playerEvent: VoiceMessagePlayerEvent,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import io.element.android.libraries.di.SingleIn
|
|||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||
|
|
@ -95,10 +95,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent ->
|
||||
val onVoiceMessageRecorderEvent = { event: VoiceMessageComposerEvents.RecorderEvent ->
|
||||
val permissionGranted = permissionState.permissionGranted
|
||||
when (event.pressEvent) {
|
||||
PressEvent.PressStart -> {
|
||||
when (event.recorderEvent) {
|
||||
VoiceMessageRecorderEvent.Start -> {
|
||||
Timber.v("Voice message record button pressed")
|
||||
when {
|
||||
permissionGranted -> {
|
||||
|
|
@ -110,12 +110,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
PressEvent.LongPressEnd -> {
|
||||
Timber.v("Voice message record button released")
|
||||
VoiceMessageRecorderEvent.Stop -> {
|
||||
Timber.v("Voice message stop button pressed")
|
||||
localCoroutineScope.finishRecording()
|
||||
}
|
||||
PressEvent.Tapped -> {
|
||||
Timber.v("Voice message record button tapped")
|
||||
VoiceMessageRecorderEvent.Cancel -> {
|
||||
Timber.v("Voice message cancel button tapped")
|
||||
localCoroutineScope.cancelRecording()
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +163,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
|
||||
val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
|
||||
when (event) {
|
||||
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||
is VoiceMessageComposerEvents.RecorderEvent -> onVoiceMessageRecorderEvent(event)
|
||||
is VoiceMessageComposerEvents.PlayerEvent -> onPlayerEvent(event.playerEvent)
|
||||
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
|
||||
onSendButtonPress()
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import io.element.android.libraries.permissions.api.aPermissionsState
|
|||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
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.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||
|
|
@ -99,7 +99,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
|
|
@ -116,13 +116,13 @@ class VoiceMessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().apply {
|
||||
eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
assertThat(keepScreenOn).isFalse()
|
||||
}
|
||||
|
||||
awaitItem().apply {
|
||||
assertThat(keepScreenOn).isTrue()
|
||||
eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
}
|
||||
|
||||
val finalState = awaitItem().apply {
|
||||
|
|
@ -139,13 +139,11 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
||||
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
|
@ -156,8 +154,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
|
|
@ -173,7 +171,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
val finalState = awaitItem().apply {
|
||||
this.eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
}
|
||||
|
|
@ -192,8 +190,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||
|
|
@ -210,8 +208,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
||||
val finalState = awaitItem().also {
|
||||
|
|
@ -229,8 +227,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
|
||||
|
|
@ -256,8 +254,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||
|
||||
val finalState = awaitItem()
|
||||
|
|
@ -274,8 +272,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||
awaitItem().apply {
|
||||
|
|
@ -296,8 +294,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
|
||||
|
||||
|
|
@ -318,15 +316,15 @@ class VoiceMessageComposerPresenterTest {
|
|||
}.test {
|
||||
// Send a normal voice message
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
skipItems(1) // Sending state
|
||||
|
||||
// Now reply with a voice message
|
||||
messageComposerContext.composerMode = aReplyMode()
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
val finalState = awaitItem() // Sending state
|
||||
|
||||
|
|
@ -345,8 +343,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
|
||||
|
|
@ -367,8 +365,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().run {
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
|
|
@ -392,8 +390,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState())
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
|
|
@ -417,8 +415,8 @@ class VoiceMessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
mediaPreProcessor.givenResult(Result.failure(Exception()))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
val previewState = awaitItem()
|
||||
|
||||
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
|
|
@ -467,7 +465,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
assertThat(analyticsService.trackedErrors).containsExactly(
|
||||
|
|
@ -491,15 +489,15 @@ class VoiceMessageComposerPresenterTest {
|
|||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
initialState.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Stop))
|
||||
voiceRecorder.assertCalls(stopped = 1)
|
||||
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(stopped = 1, started = 1)
|
||||
|
|
@ -519,7 +517,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
|
||||
// See the dialog and accept it
|
||||
awaitItem().also {
|
||||
|
|
@ -533,7 +531,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||
voiceRecorder.assertCalls(started = 1)
|
||||
|
|
@ -553,7 +551,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
|
||||
// See the dialog and accept it
|
||||
awaitItem().also {
|
||||
|
|
@ -565,7 +563,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
// Dialog is hidden, user tries to record again
|
||||
awaitItem().also {
|
||||
assertThat(it.showPermissionRationaleDialog).isFalse()
|
||||
it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
it.eventSink(VoiceMessageComposerEvents.RecorderEvent(VoiceMessageRecorderEvent.Start))
|
||||
}
|
||||
|
||||
// Dialog is shown once again
|
||||
|
|
|
|||
9
libraries/designsystem/src/main/res/drawable/ic_stop.xml
Normal file
9
libraries/designsystem/src/main/res/drawable/ic_stop.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,16V8C6,7.45 6.196,6.979 6.588,6.588C6.979,6.196 7.45,6 8,6H16C16.55,6 17.021,6.196 17.413,6.588C17.804,6.979 18,7.45 18,8V16C18,16.55 17.804,17.021 17.413,17.413C17.021,17.804 16.55,18 16,18H8C7.45,18 6.979,17.804 6.588,17.413C6.196,17.021 6,16.55 6,16Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3d5209087d7841a80f7213f26aaf8322cc5fee03466b9059e06b0daf5f489c1b
|
||||
size 10012
|
||||
oid sha256:5cd4e624c0ed8abe5d22d60992f64f81e91fa41ea7d8772acc5e3832eadbcbb7
|
||||
size 10581
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5b8fca1de81e424f01a518e3a2547b58c0e9eb7249327f3fcdb1cf62ab655972
|
||||
size 9429
|
||||
oid sha256:01eda47acbf273bf61e60dbbc466aac5e9a4eac7690af4cde2e06e7e1f190bcf
|
||||
size 10152
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e6d75686b1463d11d89e5130b160c3c446f26612e8da44981cc6994687001d80
|
||||
size 7093
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f8c96b9ce6a8d9136c56789e9e6af25b4259143f6416305c523f4e5224bc8fa8
|
||||
size 5898
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a0e8a1efaf22dd86e6e27904d72a820cfb0b5d1d38ffeb5745bfa6ca3b0a1c85
|
||||
size 6003
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7f2db2983459eae8b7538c51d125836807b29334b01fbca495c3c9630fb510c2
|
||||
size 5969
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6b6f6caef8d902d4e0b93721f212dcd3f20cde267e1782813f0c55498bca5c8e
|
||||
size 6805
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6def3059e37f5c7d6d8930df229a895f5d37b95f46a138fa4ef790bbf76b80b6
|
||||
size 6744
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:991416dcd21ebf8e2d57baac7cb42506596a1a346dd7d643b09c84c473e053b7
|
||||
size 28016
|
||||
oid sha256:9a76e1dcbc4461b6e0a1cca836a097846262a3d302befbec4486ed7ee837a8da
|
||||
size 28214
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a8b8c60473ce1b9e7451e763c960a9242bb83ca1d3769ee921db830ff6e57e7c
|
||||
size 27094
|
||||
oid sha256:25be7fa172232c508c58369f60e82fc7ec1d7362d9f701c1e698ec8d8498b482
|
||||
size 27449
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue