forked from melod1n/fast-messenger
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:
+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
|
||||
)
|
||||
)
|
||||
|
||||
+32
-82
@@ -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))
|
||||
|
||||
+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