From 47c1f623f0e69d9ad83637d04a18eb36517062c0 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Wed, 20 Aug 2025 00:31:33 +0300 Subject: [PATCH] Refactor: Introduce video message record mode This commit refactors the `ActionMode` sealed class into an enum and adds a new `RECORD_VIDEO` state. This allows for distinct actions for recording audio and video messages. Specifically, the following changes were made: - Converted `ActionMode` from a sealed class to an enum. - Added `RECORD_VIDEO` to `ActionMode`. - Updated `MessagesHistoryInputBar` to: - Animate the action button icon change between record modes. - Remove the shake animation from emoji, attachment, and mic buttons. - Updated `MessagesHistoryViewModel` to toggle between `RECORD_AUDIO` and `RECORD_VIDEO` when the action button is clicked in a record mode. - Added support for displaying `VIDEO_MESSAGE` attachments in `Attachments.kt`, including an animated circular preview. - Updated `MessageBubble` to render video messages without a background, similar to stickers. - Added `image` property to `VkVideoMessageDomain` to hold the URL for the video message preview. - Added a new drawable `rounded_photo_camera_24` for the video record button. - Updated `VkVideoMessageData` to parse and provide the square preview image URL to the domain model. --- .../fast/model/api/data/VkVideoMessageData.kt | 9 +- .../model/api/domain/VkVideoMessageDomain.kt | 3 +- .../res/drawable/rounded_photo_camera_24.xml | 11 ++ .../MessagesHistoryViewModel.kt | 24 ++-- .../fast/messageshistory/model/ActionMode.kt | 12 +- .../model/MessagesHistoryScreenState.kt | 2 +- .../presentation/MessageBubble.kt | 9 +- .../presentation/MessagesHistoryInputBar.kt | 114 +++++------------- .../presentation/attachments/Attachments.kt | 57 ++++++++- 9 files changed, 135 insertions(+), 106 deletions(-) create mode 100644 core/ui/src/main/res/drawable/rounded_photo_camera_24.xml diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt index 3432eb23..996d8984 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkVideoMessageData.kt @@ -54,9 +54,9 @@ data class VkVideoMessageData( @JsonClass(generateAdapter = true) data class Image( - val height: Int?, - val url: String?, - val width: Int?, + val height: Int, + val url: String, + val width: Int, val with_padding: Int?, ) @@ -73,6 +73,7 @@ data class VkVideoMessageData( ) fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain( - id = id + id = id, + image = image.orEmpty().filter { it.width / it.height == 1 }.maxByOrNull { it.width }?.url ) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt index a9a8dcba..f76f8289 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkVideoMessageDomain.kt @@ -3,7 +3,8 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkVideoMessageDomain( - val id: Long + val id: Long, + val image: String? ) : VkAttachment { override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE diff --git a/core/ui/src/main/res/drawable/rounded_photo_camera_24.xml b/core/ui/src/main/res/drawable/rounded_photo_camera_24.xml new file mode 100644 index 00000000..4e630477 --- /dev/null +++ b/core/ui/src/main/res/drawable/rounded_photo_camera_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index 2eeb09f9..8c6059e2 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -24,8 +24,6 @@ import androidx.lifecycle.viewModelScope import coil.imageLoader import coil.request.ImageRequest import com.conena.nanokt.collections.indexOfFirstOrNull -import com.conena.nanokt.text.isEmptyOrBlank -import com.conena.nanokt.text.isNotEmptyOrBlank import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.orDots @@ -360,15 +358,15 @@ class MessagesHistoryViewModelImpl( screenState.setValue { old -> old.copy( message = newText, - actionMode = if (newText.text.isEmptyOrBlank()) ActionMode.Record - else ActionMode.Send + actionMode = if (newText.text.isBlank()) ActionMode.RECORD_AUDIO + else ActionMode.SEND ) } updateStyles() } override fun onEmojiButtonLongClicked() { - AppSettings.Features.fastText.takeIf { it.isNotEmptyOrBlank() }?.let { text -> + AppSettings.Features.fastText.takeIf { it.isNotBlank() }?.let { text -> screenState.setValue { old -> val newText = "${old.message.text}$text" old.copy( @@ -380,19 +378,23 @@ class MessagesHistoryViewModelImpl( override fun onActionButtonClicked() { when (screenState.value.actionMode) { - ActionMode.Delete -> { + ActionMode.DELETE -> { } - ActionMode.Edit -> { + ActionMode.EDIT -> { } - ActionMode.Record -> { - + ActionMode.RECORD_AUDIO -> { + screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) } } - ActionMode.Send -> sendMessage() + ActionMode.RECORD_VIDEO -> { + screenState.setValue { it.copy(actionMode = ActionMode.RECORD_AUDIO) } + } + + ActionMode.SEND -> sendMessage() } } @@ -944,7 +946,7 @@ class MessagesHistoryViewModelImpl( screenState.setValue { old -> old.copy( message = TextFieldValue(), - actionMode = ActionMode.Record + actionMode = ActionMode.RECORD_AUDIO ) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/ActionMode.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/ActionMode.kt index ed9b52fa..fcbd5edd 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/ActionMode.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/ActionMode.kt @@ -1,9 +1,11 @@ package dev.meloda.fast.messageshistory.model -sealed class ActionMode { +enum class ActionMode { + SEND, + RECORD_AUDIO, + RECORD_VIDEO, + EDIT, + DELETE; - data object Send : ActionMode() - data object Record : ActionMode() - data object Edit : ActionMode() - data object Delete : ActionMode() + fun isRecord(): Boolean = this == RECORD_AUDIO || this == RECORD_VIDEO } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt index f4d8caf0..64e17cd5 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt @@ -38,7 +38,7 @@ data class MessagesHistoryScreenState( isLoading = true, isPaginating = false, isPaginationExhausted = false, - actionMode = ActionMode.Record, + actionMode = ActionMode.RECORD_AUDIO, chatImageUrl = null, conversation = VkConversation.EMPTY, pinnedMessage = null, diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt index 2fb3970d..e6a8a5de 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt @@ -36,6 +36,7 @@ import dev.meloda.fast.messageshistory.presentation.attachments.Attachments import dev.meloda.fast.messageshistory.presentation.attachments.Reply import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkStickerDomain +import dev.meloda.fast.model.api.domain.VkVideoMessageDomain import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.emptyImmutableList @@ -220,7 +221,13 @@ fun MessageBubble( ) .background( backgroundColor.copy( - alpha = if (attachments.firstOrNull() is VkStickerDomain) 0f + alpha = if ((attachments.firstOrNull()?.javaClass + ?: Nothing::class.java) + in listOf( + VkStickerDomain::class.java, + VkVideoMessageDomain::class.java + ) + ) 0f else 1f ) ) diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryInputBar.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryInputBar.kt index 5bce8600..001937e4 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryInputBar.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryInputBar.kt @@ -1,8 +1,12 @@ package dev.meloda.fast.messageshistory.presentation +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -28,12 +32,10 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext @@ -54,7 +56,6 @@ import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.theme.LocalThemeConfig -import kotlinx.coroutines.launch import dev.meloda.fast.ui.R as UiR @OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class) @@ -134,8 +135,6 @@ fun MessagesHistoryInputBar( Spacer(modifier = Modifier.width(6.dp)) if (showEmojiButton) { - val rotation = remember { Animatable(0f) } - Column(verticalArrangement = Arrangement.Bottom) { IconButton( onClick = { @@ -144,20 +143,6 @@ fun MessagesHistoryInputBar( HapticFeedbackConstantsCompat.REJECT ) } - scope.launch { - for (i in 20 downTo 0 step 4) { - rotation.animateTo( - targetValue = i.toFloat(), - animationSpec = tween(50) - ) - if (i > 0) { - rotation.animateTo( - targetValue = -i.toFloat(), - animationSpec = tween(50) - ) - } - } - } }, onLongClick = { if (AppSettings.General.enableHaptic) { @@ -167,7 +152,6 @@ fun MessagesHistoryInputBar( } onEmojiButtonLongClicked() }, - modifier = Modifier.rotate(rotation.value) ) { Icon( painter = painterResource(id = UiR.drawable.ic_outline_emoji_emotions_24), @@ -242,8 +226,6 @@ fun MessagesHistoryInputBar( ) if (showAttachmentButton) { - val attachmentRotation = remember { Animatable(0f) } - Column(verticalArrangement = Arrangement.Bottom) { IconButton( onClick = { @@ -253,27 +235,12 @@ fun MessagesHistoryInputBar( HapticFeedbackConstantsCompat.REJECT ) } - scope.launch { - for (i in 20 downTo 0 step 4) { - attachmentRotation.animateTo( - targetValue = i.toFloat(), - animationSpec = tween(50) - ) - if (i > 0) { - attachmentRotation.animateTo( - targetValue = -i.toFloat(), - animationSpec = tween(50) - ) - } - } - } } ) { Icon( painter = painterResource(id = UiR.drawable.round_attach_file_24), contentDescription = "Add attachment button", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.rotate(30f + attachmentRotation.value) ) } @@ -281,54 +248,37 @@ fun MessagesHistoryInputBar( } } - val micRotation = remember { Animatable(0f) } - Column(verticalArrangement = Arrangement.Bottom) { IconButton( onClick = { - if (actionMode == ActionMode.Record) { - if (AppSettings.General.enableHaptic) { - view.performHapticFeedback( - HapticFeedbackConstantsCompat.REJECT - ) - } - scope.launch { - for (i in 20 downTo 0 step 4) { - micRotation.animateTo( - targetValue = i.toFloat(), - animationSpec = tween(50) - ) - if (i > 0) { - micRotation.animateTo( - targetValue = -i.toFloat(), - animationSpec = tween(50) - ) - } - } - } - } else { - onActionButtonClicked() + onActionButtonClicked() + if (AppSettings.General.enableHaptic && actionMode.isRecord()) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK) } - }, - modifier = Modifier.rotate(micRotation.value) + } ) { - Icon( - painter = painterResource( - id = when (actionMode) { - ActionMode.Delete -> UiR.drawable.round_delete_outline_24 - ActionMode.Edit -> UiR.drawable.ic_round_done_24 - ActionMode.Record -> UiR.drawable.ic_round_mic_none_24 - ActionMode.Send -> UiR.drawable.round_send_24 - } - ), - contentDescription = when (actionMode) { - ActionMode.Delete -> "Delete message button" - ActionMode.Edit -> "Edit message button" - ActionMode.Record -> "Record audio message button" - ActionMode.Send -> "Send message button" - }, - tint = MaterialTheme.colorScheme.primary - ) + AnimatedContent( + targetState = actionMode, + transitionSpec = { + (fadeIn() + scaleIn(initialScale = 0.9f)) togetherWith + (fadeOut() + scaleOut(targetScale = 1.2f)) + } + ) { actionMode -> + Icon( + painter = painterResource( + id = when (actionMode) { + ActionMode.DELETE -> UiR.drawable.round_delete_outline_24 + ActionMode.EDIT -> UiR.drawable.ic_round_done_24 + ActionMode.RECORD_AUDIO -> UiR.drawable.ic_round_mic_none_24 + ActionMode.RECORD_VIDEO -> UiR.drawable.rounded_photo_camera_24 + ActionMode.SEND -> UiR.drawable.round_send_24 + } + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } Spacer(modifier = Modifier.height(4.dp)) diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt index 8aab16c2..090cae11 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt @@ -1,22 +1,37 @@ package dev.meloda.fast.messageshistory.presentation.attachments +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth 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.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAudioDomain @@ -25,6 +40,7 @@ import dev.meloda.fast.model.api.domain.VkLinkDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkStickerDomain import dev.meloda.fast.model.api.domain.VkVideoDomain +import dev.meloda.fast.model.api.domain.VkVideoMessageDomain import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList @@ -107,13 +123,52 @@ fun Attachments( ) } + AttachmentType.VIDEO_MESSAGE -> { + var isPlaying by remember { + mutableStateOf(false) + } + + val imageSize by animateDpAsState( + targetValue = if (isPlaying) 320.dp else 192.dp, + label = "video message preview animation", + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + + Box( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(1.dp) + ) { + AsyncImage( + model = (attachment as VkVideoMessageDomain).image, + contentDescription = null, + modifier = Modifier + .size(imageSize) + .aspectRatio(1f) + .clip(CircleShape) + .clickable { + isPlaying = !isPlaying + }, + contentScale = ContentScale.Crop + ) + } + } + else -> { Text( text = buildAnnotatedString { append("Unsupported attachment: [${attachment.type}]") addStyle(SpanStyle(fontWeight = FontWeight.Medium), 0, length) addStyle(SpanStyle(fontStyle = FontStyle.Italic), 0, length) - addStyle(SpanStyle(textDecoration = TextDecoration.Underline), 0, length) + addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + 0, + length + ) }, modifier = Modifier .fillMaxWidth()