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:
@@ -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>
|
||||
+13
-11
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+8
-1
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
+24
-74
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+56
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user