[Voice messages] Add voice recording UI (#1546)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
0c2a6468b5
commit
ffa6c43503
293 changed files with 967 additions and 52 deletions
|
|
@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
|
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.api
|
package io.element.android.features.messages.api
|
||||||
|
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hoist-able state of the message composer.
|
* Hoist-able state of the message composer.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||||
|
|
@ -67,6 +68,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
|
@ -75,7 +78,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
import io.element.android.libraries.matrix.ui.room.canRedactAsState
|
import io.element.android.libraries.matrix.ui.room.canRedactAsState
|
||||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -84,6 +87,7 @@ import timber.log.Timber
|
||||||
class MessagesPresenter @AssistedInject constructor(
|
class MessagesPresenter @AssistedInject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val composerPresenter: MessageComposerPresenter,
|
private val composerPresenter: MessageComposerPresenter,
|
||||||
|
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
|
||||||
private val timelinePresenter: TimelinePresenter,
|
private val timelinePresenter: TimelinePresenter,
|
||||||
private val actionListPresenter: ActionListPresenter,
|
private val actionListPresenter: ActionListPresenter,
|
||||||
private val customReactionPresenter: CustomReactionPresenter,
|
private val customReactionPresenter: CustomReactionPresenter,
|
||||||
|
|
@ -95,6 +99,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val clipboardHelper: ClipboardHelper,
|
private val clipboardHelper: ClipboardHelper,
|
||||||
private val preferencesStore: PreferencesStore,
|
private val preferencesStore: PreferencesStore,
|
||||||
|
private val featureFlagsService: FeatureFlagService,
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
|
|
||||||
|
|
@ -107,6 +112,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
override fun present(): MessagesState {
|
override fun present(): MessagesState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val composerState = composerPresenter.present()
|
val composerState = composerPresenter.present()
|
||||||
|
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||||
val timelineState = timelinePresenter.present()
|
val timelineState = timelinePresenter.present()
|
||||||
val actionListState = actionListPresenter.present()
|
val actionListState = actionListPresenter.present()
|
||||||
val customReactionState = customReactionPresenter.present()
|
val customReactionState = customReactionPresenter.present()
|
||||||
|
|
@ -145,6 +151,11 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||||
|
|
||||||
|
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(featureFlagsService) {
|
||||||
|
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: MessagesEvents) {
|
fun handleEvents(event: MessagesEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is MessagesEvents.HandleAction -> {
|
is MessagesEvents.HandleAction -> {
|
||||||
|
|
@ -177,6 +188,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||||
userHasPermissionToRedact = userHasPermissionToRedact,
|
userHasPermissionToRedact = userHasPermissionToRedact,
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
customReactionState = customReactionState,
|
customReactionState = customReactionState,
|
||||||
|
|
@ -187,6 +199,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
showReinvitePrompt = showReinvitePrompt,
|
showReinvitePrompt = showReinvitePrompt,
|
||||||
inviteProgress = inviteProgress.value,
|
inviteProgress = inviteProgress.value,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = enableTextFormatting,
|
||||||
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
|
@ -36,6 +37,7 @@ data class MessagesState(
|
||||||
val userHasPermissionToSendMessage: Boolean,
|
val userHasPermissionToSendMessage: Boolean,
|
||||||
val userHasPermissionToRedact: Boolean,
|
val userHasPermissionToRedact: Boolean,
|
||||||
val composerState: MessageComposerState,
|
val composerState: MessageComposerState,
|
||||||
|
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
val customReactionState: CustomReactionState,
|
val customReactionState: CustomReactionState,
|
||||||
|
|
@ -46,5 +48,6 @@ data class MessagesState(
|
||||||
val inviteProgress: Async<Unit>,
|
val inviteProgress: Async<Unit>,
|
||||||
val showReinvitePrompt: Boolean,
|
val showReinvitePrompt: Boolean,
|
||||||
val enableTextFormatting: Boolean,
|
val enableTextFormatting: Boolean,
|
||||||
|
val enableVoiceMessages: Boolean,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,12 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ fun aMessagesState() = MessagesState(
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal("Hello"),
|
mode = MessageComposerMode.Normal("Hello"),
|
||||||
),
|
),
|
||||||
|
voiceMessageComposerState = aVoiceMessageComposerState(),
|
||||||
timelineState = aTimelineState().copy(
|
timelineState = aTimelineState().copy(
|
||||||
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
|
||||||
),
|
),
|
||||||
|
|
@ -82,5 +84,6 @@ fun aMessagesState() = MessagesState(
|
||||||
inviteProgress = Async.Uninitialized,
|
inviteProgress = Async.Uninitialized,
|
||||||
showReinvitePrompt = false,
|
showReinvitePrompt = false,
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -317,8 +317,10 @@ private fun MessagesViewContent(
|
||||||
if (state.userHasPermissionToSendMessage) {
|
if (state.userHasPermissionToSendMessage) {
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
state = state.composerState,
|
state = state.composerState,
|
||||||
|
voiceMessageState = state.voiceMessageComposerState,
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
enableTextFormatting = state.enableTextFormatting,
|
enableTextFormatting = state.enableTextFormatting,
|
||||||
|
enableVoiceMessages = state.enableVoiceMessages,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.libraries.textcomposer.Message
|
import io.element.android.libraries.textcomposer.model.Message
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface MessageComposerEvents {
|
sealed interface MessageComposerEvents {
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.textcomposer.Message
|
import io.element.android.libraries.textcomposer.model.Message
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
|
|
||||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,24 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerStateProvider
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.textcomposer.Message
|
import io.element.android.libraries.textcomposer.model.Message
|
||||||
import io.element.android.libraries.textcomposer.TextComposer
|
import io.element.android.libraries.textcomposer.TextComposer
|
||||||
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageComposerView(
|
internal fun MessageComposerView(
|
||||||
state: MessageComposerState,
|
state: MessageComposerState,
|
||||||
|
voiceMessageState: VoiceMessageComposerState,
|
||||||
subcomposing: Boolean,
|
subcomposing: Boolean,
|
||||||
enableTextFormatting: Boolean,
|
enableTextFormatting: Boolean,
|
||||||
|
enableVoiceMessages: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun sendMessage(message: Message) {
|
fun sendMessage(message: Message) {
|
||||||
|
|
@ -64,9 +71,14 @@ fun MessageComposerView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onVoiceRecordButtonEvent(press: PressEvent) {
|
||||||
|
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||||
|
}
|
||||||
|
|
||||||
TextComposer(
|
TextComposer(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state.richTextEditorState,
|
state = state.richTextEditorState,
|
||||||
|
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
onRequestFocus = ::onRequestFocus,
|
onRequestFocus = ::onRequestFocus,
|
||||||
onSendMessage = ::sendMessage,
|
onSendMessage = ::sendMessage,
|
||||||
|
|
@ -76,24 +88,49 @@ fun MessageComposerView(
|
||||||
onAddAttachment = ::onAddAttachment,
|
onAddAttachment = ::onAddAttachment,
|
||||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = enableTextFormatting,
|
||||||
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
|
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
||||||
onError = ::onError,
|
onError = ::onError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MessageComposerViewPreview(@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState) = ElementPreview {
|
internal fun MessageComposerViewPreview(
|
||||||
|
@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState,
|
||||||
|
) = ElementPreview {
|
||||||
Column {
|
Column {
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
modifier = Modifier.height(IntrinsicSize.Min),
|
modifier = Modifier.height(IntrinsicSize.Min),
|
||||||
state = state,
|
state = state,
|
||||||
|
voiceMessageState = aVoiceMessageComposerState(),
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
subcomposing = false,
|
||||||
)
|
)
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
modifier = Modifier.height(200.dp),
|
modifier = Modifier.height(200.dp),
|
||||||
state = state,
|
state = state,
|
||||||
|
voiceMessageState = aVoiceMessageComposerState(),
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
|
subcomposing = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MessageComposerViewVoicePreview(
|
||||||
|
@PreviewParameter(VoiceMessageComposerStateProvider::class) state: VoiceMessageComposerState,
|
||||||
|
) = ElementPreview {
|
||||||
|
Column {
|
||||||
|
MessageComposerView(
|
||||||
|
modifier = Modifier.height(IntrinsicSize.Min),
|
||||||
|
state = aMessageComposerState(),
|
||||||
|
voiceMessageState = state,
|
||||||
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
subcomposing = false,
|
subcomposing = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
|
|
||||||
|
sealed class VoiceMessageComposerEvents {
|
||||||
|
data class RecordButtonEvent(
|
||||||
|
val pressEvent: PressEvent
|
||||||
|
): VoiceMessageComposerEvents()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
|
class VoiceMessageComposerPresenter @Inject constructor() : Presenter<VoiceMessageComposerState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): VoiceMessageComposerState {
|
||||||
|
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(VoiceMessageState.Idle) }
|
||||||
|
|
||||||
|
fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) {
|
||||||
|
PressEvent.PressStart -> {
|
||||||
|
// TODO start the recording
|
||||||
|
voiceMessageState = VoiceMessageState.Recording
|
||||||
|
}
|
||||||
|
PressEvent.LongPressEnd -> {
|
||||||
|
// TODO finish the recording
|
||||||
|
voiceMessageState = VoiceMessageState.Idle
|
||||||
|
}
|
||||||
|
PressEvent.Tapped -> {
|
||||||
|
// TODO discard the recording and show the 'hold to record' tooltip
|
||||||
|
voiceMessageState = VoiceMessageState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun handleEvents(event: VoiceMessageComposerEvents) {
|
||||||
|
when (event) {
|
||||||
|
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VoiceMessageComposerState(
|
||||||
|
voiceMessageState = voiceMessageState,
|
||||||
|
eventSink = { handleEvents(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class VoiceMessageComposerState(
|
||||||
|
val voiceMessageState: VoiceMessageState,
|
||||||
|
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* 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.features.messages.impl.voicemessages
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
|
||||||
|
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||||
|
override val values: Sequence<VoiceMessageComposerState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun aVoiceMessageComposerState(
|
||||||
|
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||||
|
) = VoiceMessageComposerState(
|
||||||
|
voiceMessageState = voiceMessageState,
|
||||||
|
eventSink = {},
|
||||||
|
)
|
||||||
|
|
@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||||
|
|
@ -73,7 +74,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
|
|
@ -619,6 +620,7 @@ class MessagesPresenterTest {
|
||||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
)
|
)
|
||||||
|
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
|
|
@ -634,6 +636,7 @@ class MessagesPresenterTest {
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
composerPresenter = messageComposerPresenter,
|
composerPresenter = messageComposerPresenter,
|
||||||
|
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
|
||||||
timelinePresenter = timelinePresenter,
|
timelinePresenter = timelinePresenter,
|
||||||
actionListPresenter = actionListPresenter,
|
actionListPresenter = actionListPresenter,
|
||||||
customReactionPresenter = customReactionPresenter,
|
customReactionPresenter = customReactionPresenter,
|
||||||
|
|
@ -645,6 +648,7 @@ class MessagesPresenterTest {
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
clipboardHelper = clipboardHelper,
|
clipboardHelper = clipboardHelper,
|
||||||
preferencesStore = preferencesStore,
|
preferencesStore = preferencesStore,
|
||||||
|
featureFlagsService = FakeFeatureFlagService(),
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
import io.element.android.libraries.textcomposer.Message
|
import io.element.android.libraries.textcomposer.model.Message
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.waitForPredicate
|
import io.element.android.tests.testutils.waitForPredicate
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* 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(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
package io.element.android.features.messages.voicemessages
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionMode
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||||
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||||
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VoiceMessageComposerPresenterTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - recording state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
|
||||||
|
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - abort recording`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
|
||||||
|
|
||||||
|
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - finish recording`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
|
||||||
|
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun createPresenter() = VoiceMessageComposerPresenter()
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.features.messages.test
|
package io.element.android.features.messages.test
|
||||||
|
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
|
||||||
class MessageComposerContextFake(
|
class MessageComposerContextFake(
|
||||||
override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
|
override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,8 @@ dependencies {
|
||||||
api(libs.matrix.richtexteditor.compose)
|
api(libs.matrix.richtexteditor.compose)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
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.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
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.testtags.testTag
|
||||||
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
|
||||||
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
|
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.SendButton
|
||||||
import io.element.android.libraries.textcomposer.components.TextFormatting
|
import io.element.android.libraries.textcomposer.components.TextFormatting
|
||||||
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
|
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.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditor
|
import io.element.android.wysiwyg.compose.RichTextEditor
|
||||||
|
|
@ -74,8 +82,10 @@ import kotlinx.collections.immutable.persistentListOf
|
||||||
@Composable
|
@Composable
|
||||||
fun TextComposer(
|
fun TextComposer(
|
||||||
state: RichTextEditorState,
|
state: RichTextEditorState,
|
||||||
|
voiceMessageState: VoiceMessageState,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
enableTextFormatting: Boolean,
|
enableTextFormatting: Boolean,
|
||||||
|
enableVoiceMessages: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showTextFormatting: Boolean = false,
|
showTextFormatting: Boolean = false,
|
||||||
subcomposing: Boolean = false,
|
subcomposing: Boolean = false,
|
||||||
|
|
@ -84,6 +94,7 @@ fun TextComposer(
|
||||||
onResetComposerMode: () -> Unit = {},
|
onResetComposerMode: () -> Unit = {},
|
||||||
onAddAttachment: () -> Unit = {},
|
onAddAttachment: () -> Unit = {},
|
||||||
onDismissTextFormatting: () -> Unit = {},
|
onDismissTextFormatting: () -> Unit = {},
|
||||||
|
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
|
||||||
onError: (Throwable) -> Unit = {},
|
onError: (Throwable) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val onSendClicked = {
|
val onSendClicked = {
|
||||||
|
|
@ -118,16 +129,34 @@ fun TextComposer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val canSendMessage by remember { derivedStateOf { state.messageHtml.isNotEmpty() } }
|
||||||
val sendButton = @Composable {
|
val sendButton = @Composable {
|
||||||
SendButton(
|
SendButton(
|
||||||
canSendMessage = state.messageHtml.isNotEmpty(),
|
canSendMessage = canSendMessage,
|
||||||
onClick = onSendClicked,
|
onClick = onSendClicked,
|
||||||
composerMode = composerMode,
|
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 textFormattingOptions = @Composable { TextFormatting(state = state) }
|
||||||
|
|
||||||
|
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
|
||||||
|
sendButton
|
||||||
|
} else {
|
||||||
|
recordButton
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordingProgress = @Composable {
|
||||||
|
RecordingProgress()
|
||||||
|
}
|
||||||
|
|
||||||
if (showTextFormatting) {
|
if (showTextFormatting) {
|
||||||
TextFormattingLayout(
|
TextFormattingLayout(
|
||||||
modifier = layoutModifier,
|
modifier = layoutModifier,
|
||||||
|
|
@ -136,14 +165,16 @@ fun TextComposer(
|
||||||
DismissTextFormattingButton(onClick = onDismissTextFormatting)
|
DismissTextFormattingButton(onClick = onDismissTextFormatting)
|
||||||
},
|
},
|
||||||
textFormatting = textFormattingOptions,
|
textFormatting = textFormattingOptions,
|
||||||
sendButton = sendButton
|
sendButton = sendButton,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
StandardLayout(
|
StandardLayout(
|
||||||
|
voiceMessageState = voiceMessageState,
|
||||||
modifier = layoutModifier,
|
modifier = layoutModifier,
|
||||||
composerOptionsButton = composerOptionsButton,
|
composerOptionsButton = composerOptionsButton,
|
||||||
textInput = textInput,
|
textInput = textInput,
|
||||||
sendButton = sendButton
|
endButton = sendOrRecordButton,
|
||||||
|
recordingProgress = recordingProgress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,33 +189,45 @@ fun TextComposer(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StandardLayout(
|
private fun StandardLayout(
|
||||||
|
voiceMessageState: VoiceMessageState,
|
||||||
textInput: @Composable () -> Unit,
|
textInput: @Composable () -> Unit,
|
||||||
composerOptionsButton: @Composable () -> Unit,
|
composerOptionsButton: @Composable () -> Unit,
|
||||||
sendButton: @Composable () -> Unit,
|
recordingProgress: @Composable () -> Unit,
|
||||||
|
endButton: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
Box(
|
if (voiceMessageState is VoiceMessageState.Recording) {
|
||||||
Modifier
|
Box(
|
||||||
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
|
modifier = Modifier
|
||||||
) {
|
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
composerOptionsButton()
|
.weight(1f)
|
||||||
}
|
) {
|
||||||
Box(
|
recordingProgress()
|
||||||
modifier = Modifier
|
}
|
||||||
.padding(bottom = 8.dp, top = 8.dp)
|
} else {
|
||||||
.weight(1f)
|
Box(
|
||||||
) {
|
Modifier
|
||||||
textInput()
|
.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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
||||||
) {
|
) {
|
||||||
sendButton()
|
endButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -438,18 +481,22 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||||
{
|
{
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("", initialFocus = true),
|
RichTextEditorState("", initialFocus = true),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message", initialFocus = true),
|
RichTextEditorState("A message", initialFocus = true),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
|
@ -457,18 +504,22 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||||
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||||
initialFocus = true
|
initialFocus = true
|
||||||
),
|
),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message without focus", initialFocus = false),
|
RichTextEditorState("A message without focus", initialFocus = false),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -480,23 +531,29 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||||
PreviewColumn(items = persistentListOf({
|
PreviewColumn(items = persistentListOf({
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("", initialFocus = false),
|
RichTextEditorState("", initialFocus = false),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
showTextFormatting = true,
|
showTextFormatting = true,
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message", initialFocus = false),
|
RichTextEditorState("A message", initialFocus = false),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
showTextFormatting = true,
|
showTextFormatting = true,
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = false),
|
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = false),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
showTextFormatting = true,
|
showTextFormatting = true,
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -507,10 +564,12 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
||||||
PreviewColumn(items = persistentListOf({
|
PreviewColumn(items = persistentListOf({
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message", initialFocus = true),
|
RichTextEditorState("A message", initialFocus = true),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -521,6 +580,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
PreviewColumn(items = persistentListOf({
|
PreviewColumn(items = persistentListOf({
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState(""),
|
RichTextEditorState(""),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -533,11 +593,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState(""),
|
RichTextEditorState(""),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = true,
|
isThreaded = true,
|
||||||
|
|
@ -550,10 +612,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message"),
|
RichTextEditorState("A message"),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = true,
|
isThreaded = true,
|
||||||
|
|
@ -569,10 +633,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message"),
|
RichTextEditorState("A message"),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -588,10 +654,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message"),
|
RichTextEditorState("A message"),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -607,10 +675,12 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
}, {
|
}, {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
RichTextEditorState("A message", initialFocus = true),
|
RichTextEditorState("A message", initialFocus = true),
|
||||||
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
@ -626,6 +696,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
|
enableVoiceMessages = true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
|
import io.element.android.libraries.textcomposer.utils.PressState
|
||||||
|
import io.element.android.libraries.textcomposer.utils.PressStateEffects
|
||||||
|
import io.element.android.libraries.textcomposer.utils.rememberPressState
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RecordButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onPressStart: () -> Unit = {},
|
||||||
|
onLongPressEnd: () -> Unit = {},
|
||||||
|
onTap: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val pressState = rememberPressState()
|
||||||
|
|
||||||
|
PressStateEffects(
|
||||||
|
pressState = pressState.value,
|
||||||
|
onPressStart = onPressStart,
|
||||||
|
onLongPressEnd = onLongPressEnd,
|
||||||
|
onTap = onTap,
|
||||||
|
)
|
||||||
|
|
||||||
|
RecordButtonView(
|
||||||
|
isPressed = pressState.value is PressState.Pressing,
|
||||||
|
modifier = modifier
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
coroutineScope.launch {
|
||||||
|
when (event.type) {
|
||||||
|
PointerEventType.Press -> pressState.press()
|
||||||
|
PointerEventType.Release -> pressState.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordButtonView(
|
||||||
|
isPressed: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = modifier
|
||||||
|
.size(48.dp),
|
||||||
|
onClick = {},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(24.dp.applyScaleUp()),
|
||||||
|
resourceId = if (isPressed) {
|
||||||
|
CommonDrawables.ic_compound_mic_on_solid
|
||||||
|
} else {
|
||||||
|
CommonDrawables.ic_compound_mic_on_outline
|
||||||
|
},
|
||||||
|
contentDescription = stringResource(CommonStrings.a11y_voice_message_record),
|
||||||
|
tint = ElementTheme.colors.iconSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun RecordButtonPreview() = ElementPreview {
|
||||||
|
Row {
|
||||||
|
RecordButtonView(isPressed = false)
|
||||||
|
RecordButtonView(isPressed = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RecordingProgress(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = ElementTheme.colors.bgSubtleSecondary,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.heightIn(26.dp)
|
||||||
|
|
||||||
|
,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
// TODO Replace with timer UI
|
||||||
|
Text(
|
||||||
|
text = "Recording...", // Not localized because it is a placeholder
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodySmMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun RecordingProgressPreview() {
|
||||||
|
RecordingProgress()
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
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.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.text.applyScaleUp
|
import io.element.android.libraries.designsystem.text.applyScaleUp
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun textInputRoundedCornerShape(
|
internal fun textInputRoundedCornerShape(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.textcomposer
|
package io.element.android.libraries.textcomposer.model
|
||||||
|
|
||||||
data class Message(
|
data class Message(
|
||||||
val html: String?,
|
val html: String?,
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.textcomposer
|
package io.element.android.libraries.textcomposer.model
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.model
|
||||||
|
|
||||||
|
sealed class PressEvent {
|
||||||
|
data object PressStart: PressEvent()
|
||||||
|
data object Tapped: PressEvent()
|
||||||
|
data object LongPressEnd: PressEvent()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.model
|
||||||
|
|
||||||
|
sealed class VoiceMessageState {
|
||||||
|
data object Idle: VoiceMessageState()
|
||||||
|
data object Recording: VoiceMessageState()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of a press gesture.
|
||||||
|
*/
|
||||||
|
internal sealed class PressState {
|
||||||
|
data class Idle(
|
||||||
|
val lastPress: Pressing?
|
||||||
|
) : PressState()
|
||||||
|
|
||||||
|
sealed class Pressing : PressState()
|
||||||
|
data object Tapping : Pressing()
|
||||||
|
data object LongPressing : Pressing()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.utils
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React to [PressState] changes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PressStateEffects(
|
||||||
|
pressState: PressState,
|
||||||
|
onPressStart: () -> Unit = {},
|
||||||
|
onLongPressStart: () -> Unit = {},
|
||||||
|
onTap: () -> Unit = {},
|
||||||
|
onLongPressEnd: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
LaunchedEffect(pressState) {
|
||||||
|
when (pressState) {
|
||||||
|
is PressState.Idle ->
|
||||||
|
when (pressState.lastPress) {
|
||||||
|
PressState.Tapping -> onTap()
|
||||||
|
PressState.LongPressing -> onLongPressEnd()
|
||||||
|
null -> {} // Do nothing
|
||||||
|
}
|
||||||
|
is PressState.LongPressing -> onLongPressStart()
|
||||||
|
PressState.Tapping -> onPressStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.utils
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun rememberPressState(
|
||||||
|
longPressTimeoutMillis: Long = LocalViewConfiguration.current.longPressTimeoutMillis,
|
||||||
|
): PressStateHolder {
|
||||||
|
return remember(longPressTimeoutMillis) {
|
||||||
|
PressStateHolder(longPressTimeoutMillis = longPressTimeoutMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State machine that keeps track of the pressed state.
|
||||||
|
*
|
||||||
|
* When a press is started, the state will transition through:
|
||||||
|
* [PressState.Idle] -> [PressState.Tapping] -> ...
|
||||||
|
*
|
||||||
|
* If a press is held for a longer time, the state will continue through:
|
||||||
|
* ... -> [PressState.LongPressing] -> ...
|
||||||
|
*
|
||||||
|
* When the press is released the states will then transition back to idle.
|
||||||
|
* ... -> [PressState.Idle]
|
||||||
|
*
|
||||||
|
* Whether a press should be considered a tap or a long press can be determined by
|
||||||
|
* looking at the last press when in the idle state.
|
||||||
|
*
|
||||||
|
* @see [PressStateEffects]
|
||||||
|
* @see [rememberPressState]
|
||||||
|
*/
|
||||||
|
internal class PressStateHolder(
|
||||||
|
private val longPressTimeoutMillis: Long,
|
||||||
|
) : State<PressState> {
|
||||||
|
private var state: PressState by mutableStateOf(PressState.Idle(lastPress = null))
|
||||||
|
|
||||||
|
override val value: PressState
|
||||||
|
get() = state
|
||||||
|
|
||||||
|
private var longPressTimer: Job? = null
|
||||||
|
|
||||||
|
suspend fun press() = coroutineScope {
|
||||||
|
when (state) {
|
||||||
|
is PressState.Idle -> {
|
||||||
|
state = PressState.Tapping
|
||||||
|
}
|
||||||
|
is PressState.Pressing ->
|
||||||
|
Timber.e("Pointer pressed but it has not been released")
|
||||||
|
}
|
||||||
|
|
||||||
|
longPressTimer = launch {
|
||||||
|
delay(longPressTimeoutMillis)
|
||||||
|
yield()
|
||||||
|
|
||||||
|
if (isActive && state == PressState.Tapping) {
|
||||||
|
state = PressState.LongPressing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
longPressTimer?.cancel()
|
||||||
|
longPressTimer = null
|
||||||
|
when (val lastState = state) {
|
||||||
|
is PressState.Pressing ->
|
||||||
|
state = PressState.Idle(lastPress = lastState)
|
||||||
|
is PressState.Idle ->
|
||||||
|
Timber.e("Pointer pressed but it has not been released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer.utils
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.textcomposer.utils.PressState.Idle
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class) class PressStateHolderTest {
|
||||||
|
companion object {
|
||||||
|
const val LONG_PRESS_TIMEOUT_MILLIS = 1L
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
fun `it starts in idle state`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when press, it moves to tapping state`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
val press = async { stateHolder.press() }
|
||||||
|
advanceTimeBy(1.milliseconds)
|
||||||
|
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||||
|
press.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when release after short delay, it moves through tap states`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
val press = async { stateHolder.press() }
|
||||||
|
advanceTimeBy(1.milliseconds)
|
||||||
|
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||||
|
stateHolder.release()
|
||||||
|
advanceTimeBy(1.milliseconds) // wait for the long press timeout which should not be triggered
|
||||||
|
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.Tapping))
|
||||||
|
press.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when hold, it moves through long press states`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
val press = async { stateHolder.press() }
|
||||||
|
advanceTimeBy(1.milliseconds)
|
||||||
|
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||||
|
advanceTimeBy(1.milliseconds)
|
||||||
|
assertThat(stateHolder.value).isEqualTo(PressState.LongPressing)
|
||||||
|
stateHolder.release()
|
||||||
|
assertThat(stateHolder.value).isEqualTo(Idle(lastPress = PressState.LongPressing))
|
||||||
|
press.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when release and repress, it doesn't enter long press states`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
val press1 = async { stateHolder.press() }
|
||||||
|
advanceTimeBy(1.milliseconds)
|
||||||
|
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||||
|
stateHolder.release()
|
||||||
|
val press2 = async { stateHolder.press() }
|
||||||
|
advanceTimeBy(1.milliseconds)
|
||||||
|
assertThat(stateHolder.value).isEqualTo(PressState.Tapping)
|
||||||
|
press1.await()
|
||||||
|
press2.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when press twice without releasing, it doesn't throw an error`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
stateHolder.press()
|
||||||
|
stateHolder.press()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when release without first pressing, it doesn't throw an error`() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
stateHolder.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when release twice without pressing, it doesn't throw an error `() = runTest {
|
||||||
|
val stateHolder = createStateHolder()
|
||||||
|
stateHolder.press()
|
||||||
|
stateHolder.release()
|
||||||
|
stateHolder.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createStateHolder() =
|
||||||
|
PressStateHolder(
|
||||||
|
LONG_PRESS_TIMEOUT_MILLIS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="a11y_delete">"Delete"</string>
|
||||||
<string name="a11y_hide_password">"Hide password"</string>
|
<string name="a11y_hide_password">"Hide password"</string>
|
||||||
<string name="a11y_notifications_mentions_only">"Mentions only"</string>
|
<string name="a11y_notifications_mentions_only">"Mentions only"</string>
|
||||||
<string name="a11y_notifications_muted">"Muted"</string>
|
<string name="a11y_notifications_muted">"Muted"</string>
|
||||||
|
<string name="a11y_pause">"Pause"</string>
|
||||||
|
<string name="a11y_play">"Play"</string>
|
||||||
<string name="a11y_poll">"Poll"</string>
|
<string name="a11y_poll">"Poll"</string>
|
||||||
<string name="a11y_poll_end">"Ended poll"</string>
|
<string name="a11y_poll_end">"Ended poll"</string>
|
||||||
<string name="a11y_send_files">"Send files"</string>
|
<string name="a11y_send_files">"Send files"</string>
|
||||||
<string name="a11y_show_password">"Show password"</string>
|
<string name="a11y_show_password">"Show password"</string>
|
||||||
<string name="a11y_user_menu">"User menu"</string>
|
<string name="a11y_user_menu">"User menu"</string>
|
||||||
|
<string name="a11y_voice_message_record">"Record voice message. Double tap and hold to record. Release to end recording."</string>
|
||||||
<string name="action_accept">"Accept"</string>
|
<string name="action_accept">"Accept"</string>
|
||||||
<string name="action_add_to_timeline">"Add to timeline"</string>
|
<string name="action_add_to_timeline">"Add to timeline"</string>
|
||||||
<string name="action_back">"Back"</string>
|
<string name="action_back">"Back"</string>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b91884f47f7789ccb1e22b29be6cfcfa6fa5975614145eedd4d884b867cfcf0f
|
oid sha256:7b8e9c99cb13a27ebc80c664d185cd3e3ad7b2bf6a7a944022c662ba1ee59ab7
|
||||||
size 18458
|
size 18876
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0c0892fa19589139b4d47a514f0a56486ab47f82b94ef9426f8bb623be7cc0cb
|
oid sha256:6e26cc86e44601b64ed04e62e2e714252896d669c98d5aebb0b0df1d852b8ab6
|
||||||
size 16786
|
size 17261
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:96999517a90c46cc256a1703eb0919adc81682478344e8dab922f39ebb000530
|
||||||
|
size 8420
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a7be984402697ade0648a7c7cf6785f6f22ac3f3fbb44bc3e1a71be73400461f
|
||||||
|
size 8057
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue