[a11y] voice message improvements (#5980)

* A11Y: ensure a11y focus is not lost and reset to the back button when the user start playing a pending voice message.

* A11Y: ensure a11y focus is not lost and reset to the back button when the user use the keyboard to focus the send button and press the space bar to perform a click.

* Cleanup code. This if was not necessary.

* Small rework to prepare a bugfix. No behavior / UI change.

* Ensure that the keyboard focus and accessibility focus is not lost when deleting a pending voice message.

* Update screenshots

* Improve code readability.

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2026-01-13 09:23:17 +01:00
committed by GitHub
15 changed files with 336 additions and 317 deletions

View File

@@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -39,9 +39,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility
@@ -61,6 +62,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.IconColorButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
@@ -70,11 +72,11 @@ import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.textcomposer.components.SendButton
import io.element.android.libraries.textcomposer.components.SendButtonIcon
import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButtonIcon
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButtonIcon
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
@@ -123,9 +125,6 @@ fun TextComposer(
is TextEditorState.Markdown -> state.state.text.value()
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
}
val onSendClick = {
onSendMessage()
}
val onPlayVoiceMessageClick = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Play)
@@ -143,26 +142,6 @@ fun TextComposer(
.fillMaxSize()
.height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
IconColorButton(
onClick = onAddAttachment,
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
)
}
}
}
}
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) {
@@ -234,55 +213,137 @@ fun TextComposer(
}
}
val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClick,
composerMode = composerMode,
)
}
val recordVoiceButton = @Composable {
VoiceMessageRecorderButton(
isRecording = voiceMessageState is VoiceMessageState.Recording,
onEvent = onVoiceRecorderEvent,
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = onSendVoiceMessage,
composerMode = composerMode,
)
}
val uploadVoiceProgress = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let {
@Composable { TextFormatting(state = it.richTextEditorState) }
}
val sendOrRecordButton = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> uploadVoiceProgress
false -> sendVoiceButton
}
}
else -> sendButton
val hapticFeedback = LocalHapticFeedback.current
fun performHapticFeedback() {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val endButtonA11y = endButtonA11y(
composerMode = composerMode,
voiceMessageState = voiceMessageState,
canSendMessage = canSendMessage,
)
@Composable
fun rememberEndButtonParams() = remember(
composerMode.isEditing,
voiceMessageState.endButtonKey(),
canSendTextMessage,
) {
when {
!canSendTextMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_record,
endButtonClick = {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Start)
},
endButtonContent = @Composable {
VoiceMessageRecorderButtonIcon(
isRecording = false,
)
}
)
is VoiceMessageState.Recording -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_stop_recording,
endButtonClick = {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Stop)
},
endButtonContent = @Composable {
VoiceMessageRecorderButtonIcon(
isRecording = true,
)
}
)
is VoiceMessageState.Preview -> if (voiceMessageState.isSending) {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.common_sending,
endButtonClick = {},
endButtonContent = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
)
} else {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_voice_message,
endButtonClick = {
onSendVoiceMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = composerMode.isEditing,
)
},
)
}
}
composerMode.isEditing -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = true,
)
},
)
else -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = false,
)
},
)
}
}
@Composable
fun rememberEndButtonParamsFormatting() = remember(composerMode.isEditing, canSendTextMessage) {
if (composerMode.isEditing) {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = true,
)
},
)
} else {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = false,
)
},
)
}
}
val voiceRecording = @Composable {
when (voiceMessageState) {
@@ -307,17 +368,8 @@ fun TextComposer(
}
}
val voiceDeleteButton = @Composable {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) })
else -> {}
}
}
if (showTextFormatting && textFormattingOptions != null) {
val endButtonParams = rememberEndButtonParamsFormatting()
TextFormattingLayout(
modifier = layoutModifier,
isRoomEncrypted = state.isRoomEncrypted,
@@ -330,20 +382,21 @@ fun TextComposer(
)
},
textFormatting = textFormattingOptions,
endButtonA11y = endButtonA11y,
sendButton = sendButton,
endButtonParams = endButtonParams,
)
} else {
val endButtonParams = rememberEndButtonParams()
StandardLayout(
composerMode = composerMode,
voiceMessageState = voiceMessageState,
isRoomEncrypted = state.isRoomEncrypted,
modifier = layoutModifier,
composerOptionsButton = composerOptionsButton,
textInput = textInput,
endButton = sendOrRecordButton,
endButtonA11y = endButtonA11y,
endButtonParams = endButtonParams,
voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton,
onAddAttachment = onAddAttachment,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onVoiceRecorderEvent = onVoiceRecorderEvent,
)
}
@@ -367,49 +420,23 @@ fun TextComposer(
}
}
@ReadOnlyComposable
@Composable
private fun endButtonA11y(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState,
canSendMessage: Boolean,
): (SemanticsPropertyReceiver) -> Unit {
val a11ySendButtonDescription = stringResource(
id = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) {
CommonStrings.a11y_voice_message_stop_recording
} else {
CommonStrings.a11y_voice_message_record
}
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> CommonStrings.common_sending
false -> CommonStrings.action_send_voice_message
}
}
composerMode.isEditing -> CommonStrings.action_send_edited_message
else -> CommonStrings.action_send_message
}
)
val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = {
contentDescription = a11ySendButtonDescription
onClick(null, null)
}
return endButtonA11y
}
private data class EndButtonParams(
val endButtonContentDescriptionResId: Int,
val endButtonClick: () -> Unit,
val endButtonContent: @Composable () -> Unit,
)
@Composable
private fun StandardLayout(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState,
isRoomEncrypted: Boolean?,
textInput: @Composable () -> Unit,
composerOptionsButton: @Composable () -> Unit,
voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
endButtonParams: EndButtonParams,
onAddAttachment: () -> Unit,
onDeleteVoiceMessage: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@@ -419,50 +446,80 @@ private fun StandardLayout(
Spacer(Modifier.height(4.dp))
}
Row(verticalAlignment = Alignment.Bottom) {
if (voiceMessageState !is VoiceMessageState.Idle) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box(
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(12.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(19.dp))
}
else -> {
val endPadding = if (voiceMessageState is VoiceMessageState.Idle) 0.dp else 3.dp
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
IconButton(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.padding(top = 5.dp, bottom = 5.dp, start = 3.dp, end = endPadding)
.size(48.dp),
contentAlignment = Alignment.Center,
onClick = {
if (voiceMessageState is VoiceMessageState.Idle) {
onAddAttachment()
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview -> if (!voiceMessageState.isSending) {
onDeleteVoiceMessage()
}
is VoiceMessageState.Recording ->
onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel)
}
}
},
) {
voiceDeleteButton()
if (voiceMessageState is VoiceMessageState.Idle) {
Icon(
modifier = Modifier
.clip(CircleShape)
.size(30.dp)
.background(ElementTheme.colors.iconPrimary)
.padding(3.dp),
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
tint = ElementTheme.colors.iconOnSolidPrimary
)
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButtonIcon(enabled = !voiceMessageState.isSending)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButtonIcon(enabled = true)
}
}
}
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
voiceRecording()
}
} else {
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
) {
composerOptionsButton()
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
textInput()
}
}
Box(
Modifier
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
if (voiceMessageState is VoiceMessageState.Idle) {
textInput()
} else {
voiceRecording()
}
}
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId)
IconButton(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp)
.clearAndSetSemantics(endButtonA11y),
contentAlignment = Alignment.Center,
) {
endButton()
}
.clearAndSetSemantics {
contentDescription = endButtonContentDescription
onClick(null, null)
},
onClick = endButtonParams.endButtonClick,
content = endButtonParams.endButtonContent,
)
}
}
}
@@ -495,8 +552,7 @@ private fun TextFormattingLayout(
textInput: @Composable () -> Unit,
dismissTextFormattingButton: @Composable () -> Unit,
textFormatting: @Composable () -> Unit,
sendButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
endButtonParams: EndButtonParams,
modifier: Modifier = Modifier
) {
Column(
@@ -527,16 +583,22 @@ private fun TextFormattingLayout(
Box(modifier = Modifier.weight(1f)) {
textFormatting()
}
Box(
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId)
IconButton(
modifier = Modifier
.padding(
start = 14.dp,
end = 6.dp,
)
.clearAndSetSemantics(endButtonA11y)
) {
sendButton()
}
.size(48.dp)
.clearAndSetSemantics {
contentDescription = endButtonContentDescription
onClick(null, null)
},
onClick = endButtonParams.endButtonClick,
content = endButtonParams.endButtonContent,
)
}
}
}
@@ -596,6 +658,12 @@ private fun TextInputBox(
}
}
private fun VoiceMessageState.endButtonKey() = when (this) {
is VoiceMessageState.Idle -> "Idle"
is VoiceMessageState.Preview -> "Preview_$isSending"
is VoiceMessageState.Recording -> "Recording"
}
private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf(
aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted),

View File

@@ -29,9 +29,6 @@ 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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
/**
* Send button for the message composer.
@@ -39,50 +36,42 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
* Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev
*/
@Composable
internal fun SendButton(
internal fun SendButtonIcon(
canSendMessage: Boolean,
onClick: () -> Unit,
composerMode: MessageComposerMode,
isEditing: Boolean,
modifier: Modifier = Modifier,
) {
IconButton(
val iconVector = when {
isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = modifier
.size(48.dp),
onClick = onClick,
enabled = canSendMessage,
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
Icon(
modifier = Modifier
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
Icon(
modifier = Modifier
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconQuaternary
ElementTheme.colors.iconPrimary
}
)
}
} else {
ElementTheme.colors.iconQuaternary
}
)
}
}
@@ -113,13 +102,19 @@ private fun Modifier.buttonBackgroundModifier(
@PreviewsDayNight
@Composable
internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "")
internal fun SendButtonIconPreview() = ElementPreview {
Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = true, onClick = {}, composerMode = editMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = editMode)
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = true, isEditing = false)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = false, isEditing = false)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = true, isEditing = true)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = false, isEditing = true)
}
}
}

View File

@@ -23,41 +23,35 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun VoiceMessageDeleteButton(
fun VoiceMessageDeleteButtonIcon(
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier
.size(48.dp),
enabled = enabled,
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(CommonStrings.a11y_delete),
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
Icon(
modifier = modifier.size(24.dp),
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(CommonStrings.a11y_delete),
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageDeleteButtonPreview() = ElementPreview {
internal fun VoiceMessageDeleteButtonIconPreview() = ElementPreview {
Row {
VoiceMessageDeleteButton(
enabled = true,
onClick = {},
)
VoiceMessageDeleteButton(
enabled = false,
onClick = {},
)
IconButton(onClick = {}) {
VoiceMessageDeleteButtonIcon(
enabled = true,
)
}
IconButton(onClick = {}) {
VoiceMessageDeleteButtonIcon(
enabled = false,
)
}
}
}

View File

@@ -67,22 +67,12 @@ internal fun VoiceMessagePreview(
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isPlaying) {
PlayerButton(
type = PlayerButtonType.Pause,
onClick = onPauseClick,
enabled = isInteractive,
)
} else {
PlayerButton(
type = PlayerButtonType.Play,
onClick = onPlayClick,
enabled = isInteractive
)
}
PlayerButton(
type = if (isPlaying) PlayerButtonType.Pause else PlayerButtonType.Play,
onClick = if (isPlaying) onPauseClick else onPlayClick,
enabled = isInteractive,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = time.formatShort(),
color = ElementTheme.colors.textSecondary,
@@ -90,9 +80,7 @@ internal fun VoiceMessagePreview(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(12.dp))
WaveformPlaybackView(
modifier = Modifier
.weight(1f)

View File

@@ -14,9 +14,8 @@ 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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -25,49 +24,25 @@ 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
@Composable
internal fun VoiceMessageRecorderButton(
internal fun VoiceMessageRecorderButtonIcon(
isRecording: Boolean,
onEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) {
StopButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
StopButton(modifier)
} else {
StartButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
StartButton(modifier)
}
}
@Composable
private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
modifier = modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(),
// Note: accessibility is managed in TextComposer.
contentDescription = null,
@@ -77,41 +52,40 @@ private fun StartButton(
@Composable
private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) {
Box(
Modifier
modifier
.size(36.dp)
.background(
color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape,
)
)
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
internal fun VoiceMessageRecorderButtonIconPreview() = ElementPreview {
Row {
VoiceMessageRecorderButton(
isRecording = false,
onEvent = {},
)
VoiceMessageRecorderButton(
isRecording = true,
onEvent = {},
)
IconButton(onClick = {}) {
VoiceMessageRecorderButtonIcon(
isRecording = false,
)
}
IconButton(onClick = {}) {
VoiceMessageRecorderButtonIcon(
isRecording = true,
)
}
}
}