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.
This commit is contained in:
2025-08-20 00:31:33 +03:00
parent 600aed40e7
commit 47c1f623f0
9 changed files with 135 additions and 106 deletions
@@ -54,9 +54,9 @@ data class VkVideoMessageData(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Image( data class Image(
val height: Int?, val height: Int,
val url: String?, val url: String,
val width: Int?, val width: Int,
val with_padding: Int?, val with_padding: Int?,
) )
@@ -73,6 +73,7 @@ data class VkVideoMessageData(
) )
fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain( fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain(
id = id id = id,
image = image.orEmpty().filter { it.width / it.height == 1 }.maxByOrNull { it.width }?.url
) )
} }
@@ -3,7 +3,8 @@ package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
data class VkVideoMessageDomain( data class VkVideoMessageDomain(
val id: Long val id: Long,
val image: String?
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,700Q555,700 607.5,647.5Q660,595 660,520Q660,445 607.5,392.5Q555,340 480,340Q405,340 352.5,392.5Q300,445 300,520Q300,595 352.5,647.5Q405,700 480,700ZM480,620Q438,620 409,591Q380,562 380,520Q380,478 409,449Q438,420 480,420Q522,420 551,449Q580,478 580,520Q580,562 551,591Q522,620 480,620ZM160,840Q127,840 103.5,816.5Q80,793 80,760L80,280Q80,247 103.5,223.5Q127,200 160,200L286,200L336,146Q347,134 362.5,127Q378,120 395,120L565,120Q582,120 597.5,127Q613,134 624,146L674,200L800,200Q833,200 856.5,223.5Q880,247 880,280L880,760Q880,793 856.5,816.5Q833,840 800,840L160,840ZM160,760L800,760Q800,760 800,760Q800,760 800,760L800,280Q800,280 800,280Q800,280 800,280L638,280L565,200L395,200L322,280L160,280Q160,280 160,280Q160,280 160,280L160,760Q160,760 160,760Q160,760 160,760ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520L480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Z" />
</vector>
@@ -24,8 +24,6 @@ import androidx.lifecycle.viewModelScope
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull 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.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.orDots import dev.meloda.fast.common.extensions.orDots
@@ -360,15 +358,15 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
message = newText, message = newText,
actionMode = if (newText.text.isEmptyOrBlank()) ActionMode.Record actionMode = if (newText.text.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.Send else ActionMode.SEND
) )
} }
updateStyles() updateStyles()
} }
override fun onEmojiButtonLongClicked() { override fun onEmojiButtonLongClicked() {
AppSettings.Features.fastText.takeIf { it.isNotEmptyOrBlank() }?.let { text -> AppSettings.Features.fastText.takeIf { it.isNotBlank() }?.let { text ->
screenState.setValue { old -> screenState.setValue { old ->
val newText = "${old.message.text}$text" val newText = "${old.message.text}$text"
old.copy( old.copy(
@@ -380,19 +378,23 @@ class MessagesHistoryViewModelImpl(
override fun onActionButtonClicked() { override fun onActionButtonClicked() {
when (screenState.value.actionMode) { 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 -> screenState.setValue { old ->
old.copy( old.copy(
message = TextFieldValue(), message = TextFieldValue(),
actionMode = ActionMode.Record actionMode = ActionMode.RECORD_AUDIO
) )
} }
@@ -1,9 +1,11 @@
package dev.meloda.fast.messageshistory.model package dev.meloda.fast.messageshistory.model
sealed class ActionMode { enum class ActionMode {
SEND,
RECORD_AUDIO,
RECORD_VIDEO,
EDIT,
DELETE;
data object Send : ActionMode() fun isRecord(): Boolean = this == RECORD_AUDIO || this == RECORD_VIDEO
data object Record : ActionMode()
data object Edit : ActionMode()
data object Delete : ActionMode()
} }
@@ -38,7 +38,7 @@ data class MessagesHistoryScreenState(
isLoading = true, isLoading = true,
isPaginating = false, isPaginating = false,
isPaginationExhausted = false, isPaginationExhausted = false,
actionMode = ActionMode.Record, actionMode = ActionMode.RECORD_AUDIO,
chatImageUrl = null, chatImageUrl = null,
conversation = VkConversation.EMPTY, conversation = VkConversation.EMPTY,
pinnedMessage = null, pinnedMessage = null,
@@ -36,6 +36,7 @@ import dev.meloda.fast.messageshistory.presentation.attachments.Attachments
import dev.meloda.fast.messageshistory.presentation.attachments.Reply import dev.meloda.fast.messageshistory.presentation.attachments.Reply
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkStickerDomain 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.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.emptyImmutableList
@@ -220,7 +221,13 @@ fun MessageBubble(
) )
.background( .background(
backgroundColor.copy( 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 else 1f
) )
) )
@@ -1,8 +1,12 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable import androidx.compose.animation.fadeIn
import androidx.compose.animation.core.tween 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -28,12 +32,10 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext 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.messageshistory.model.ActionMode
import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class)
@@ -134,8 +135,6 @@ fun MessagesHistoryInputBar(
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
if (showEmojiButton) { if (showEmojiButton) {
val rotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) { Column(verticalArrangement = Arrangement.Bottom) {
IconButton( IconButton(
onClick = { onClick = {
@@ -144,20 +143,6 @@ fun MessagesHistoryInputBar(
HapticFeedbackConstantsCompat.REJECT 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 = { onLongClick = {
if (AppSettings.General.enableHaptic) { if (AppSettings.General.enableHaptic) {
@@ -167,7 +152,6 @@ fun MessagesHistoryInputBar(
} }
onEmojiButtonLongClicked() onEmojiButtonLongClicked()
}, },
modifier = Modifier.rotate(rotation.value)
) { ) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_outline_emoji_emotions_24), painter = painterResource(id = UiR.drawable.ic_outline_emoji_emotions_24),
@@ -242,8 +226,6 @@ fun MessagesHistoryInputBar(
) )
if (showAttachmentButton) { if (showAttachmentButton) {
val attachmentRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) { Column(verticalArrangement = Arrangement.Bottom) {
IconButton( IconButton(
onClick = { onClick = {
@@ -253,27 +235,12 @@ fun MessagesHistoryInputBar(
HapticFeedbackConstantsCompat.REJECT 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( Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24), painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button", contentDescription = "Add attachment button",
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.rotate(30f + attachmentRotation.value)
) )
} }
@@ -281,56 +248,39 @@ fun MessagesHistoryInputBar(
} }
} }
val micRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) { Column(verticalArrangement = Arrangement.Bottom) {
IconButton( IconButton(
onClick = { 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)
) { ) {
AnimatedContent(
targetState = actionMode,
transitionSpec = {
(fadeIn() + scaleIn(initialScale = 0.9f)) togetherWith
(fadeOut() + scaleOut(targetScale = 1.2f))
}
) { actionMode ->
Icon( Icon(
painter = painterResource( painter = painterResource(
id = when (actionMode) { id = when (actionMode) {
ActionMode.Delete -> UiR.drawable.round_delete_outline_24 ActionMode.DELETE -> UiR.drawable.round_delete_outline_24
ActionMode.Edit -> UiR.drawable.ic_round_done_24 ActionMode.EDIT -> UiR.drawable.ic_round_done_24
ActionMode.Record -> UiR.drawable.ic_round_mic_none_24 ActionMode.RECORD_AUDIO -> UiR.drawable.ic_round_mic_none_24
ActionMode.Send -> UiR.drawable.round_send_24 ActionMode.RECORD_VIDEO -> UiR.drawable.rounded_photo_camera_24
ActionMode.SEND -> UiR.drawable.round_send_24
} }
), ),
contentDescription = when (actionMode) { contentDescription = null,
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 tint = MaterialTheme.colorScheme.primary
) )
} }
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
@@ -1,22 +1,37 @@
package dev.meloda.fast.messageshistory.presentation.attachments 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.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAudioDomain 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.VkPhotoDomain
import dev.meloda.fast.model.api.domain.VkStickerDomain import dev.meloda.fast.model.api.domain.VkStickerDomain
import dev.meloda.fast.model.api.domain.VkVideoDomain 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
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList 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 -> { else -> {
Text( Text(
text = buildAnnotatedString { text = buildAnnotatedString {
append("Unsupported attachment: [${attachment.type}]") append("Unsupported attachment: [${attachment.type}]")
addStyle(SpanStyle(fontWeight = FontWeight.Medium), 0, length) addStyle(SpanStyle(fontWeight = FontWeight.Medium), 0, length)
addStyle(SpanStyle(fontStyle = FontStyle.Italic), 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()