feat(messages): Implement reply functionality

This commit introduces the ability to reply to messages.

- **API & Data Layer:**
  - Replaced `replyTo` parameter with `forward` in `sendMessage` calls across the data, domain, and repository layers to support the new reply mechanism.

- **ViewModel:**
  - Added logic to handle the reply state, including storing the replied message's ID (`replyToCmId`).
  - When a message is sent, it now correctly constructs a `forward` JSON object if it is a reply.
  - The UI state (`MessagesHistoryScreenState`) is updated to show and hide the reply preview.
  - Added a `onReplyCloseClicked` handler to cancel a reply.
  - The ViewModel interface was removed, and the implementation class `MessagesHistoryViewModelImpl` is used directly.

- **UI (Compose):**
  - A new `ReplyContainer` is displayed above the message input bar when a reply is active.
  - The input bar's corner radius animates to integrate with the reply container.
  - Added a `FocusRequester` to automatically focus the input field when the reply action is selected.
  - Added spacing in the message list to prevent the reply preview from overlapping messages.
  - The message options dialog now passes the `messageId` and `cmId` when an option is picked.
This commit is contained in:
2025-12-03 06:12:44 +03:00
parent 3e05744a18
commit 723555f634
4 changed files with 216 additions and 0 deletions
@@ -0,0 +1,66 @@
package dev.meloda.fast.messageshistory
import android.os.Bundle
import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessageNavigation
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.flow.StateFlow
interface MessagesHistoryViewModel {
val screenState: StateFlow<MessagesHistoryScreenState>
val navigation: StateFlow<MessageNavigation?>
val messages: StateFlow<List<VkMessage>>
val uiMessages: StateFlow<List<UiItem>>
val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>>
val inputFieldFocusRequester: StateFlow<Boolean>
val isNeedToScrollToIndex: StateFlow<Int?>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onNavigationConsumed()
fun onTopBarClicked()
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle)
fun onDialogDismissed(dialog: MessageDialog)
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle)
fun onScrolledToIndex()
fun onCloseButtonClicked()
fun onRefresh()
fun onAttachmentButtonClicked()
fun onMessageInputChanged(newText: TextFieldValue)
fun onEmojiButtonLongClicked()
fun onActionButtonClicked()
fun onPaginationConditionsMet()
fun onMessageClicked(messageId: Long)
fun onMessageLongClicked(messageId: Long)
fun onPinnedMessageClicked(messageId: Long)
fun onUnpinMessageClicked()
fun onDeleteSelectedMessagesClicked()
fun onBoldClicked()
fun onItalicClicked()
fun onUnderlineClicked()
fun onLinkClicked()
fun onRegularClicked()
fun onReplyCloseClicked()
}
@@ -0,0 +1,132 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R
@Composable
fun ReplyContainer(
onCloseClicked: () -> Unit = {},
title: String,
text: String?,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
) {
Row(
modifier = modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(
RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
)
.background(backgroundColor)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.round_reply_24px),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
AnimatedVisibility(text != null) {
Text(
text = text.orEmpty(),
style = MaterialTheme.typography.bodySmall,
)
}
}
RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = onCloseClicked
) {
Icon(
painter = painterResource(R.drawable.round_close_24px),
contentDescription = null,
)
}
}
}
@Composable
fun RippledClickContainer(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp),
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.clip(shape)
.clickable(
interactionSource = null,
indication = ripple(),
onClick = onClick
),
contentAlignment = Alignment.Center
) {
content()
}
}
@Preview
@Composable
private fun ReplyContainerPreview() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
ReplyContainer(
onCloseClicked = {},
title = "В ответ Ишак",
text = "Приветствую тебя, Ишак!",
)
}
}