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)
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
)
}
@@ -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
@@ -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.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
)
}
@@ -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
}
@@ -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,
@@ -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
)
)
@@ -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,56 +248,39 @@ 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()
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(
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
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 = when (actionMode) {
ActionMode.Delete -> "Delete message button"
ActionMode.Edit -> "Edit message button"
ActionMode.Record -> "Record audio message button"
ActionMode.Send -> "Send message button"
},
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
@@ -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()