feat(messages): add message selection and actions

This commit is contained in:
2025-03-26 22:06:55 +03:00
parent 296c3ce7a0
commit 4d18c86f04
12 changed files with 380 additions and 180 deletions
@@ -170,7 +170,6 @@ fun MainScreen(
conversationsScreen( conversationsScreen(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked,
onCreateChatClicked = onCreateChatClicked, onCreateChatClicked = onCreateChatClicked,
navController = navController, navController = navController,
) )
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,9V7.41c0,-0.89 -1.08,-1.34 -1.71,-0.71L3.7,11.29c-0.39,0.39 -0.39,1.02 0,1.41l4.59,4.59c0.63,0.63 1.71,0.19 1.71,-0.7V14.9c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7,7.56c0,-0.94 -1.14,-1.42 -1.81,-0.75L0.71,11.29c-0.39,0.39 -0.39,1.02 0,1.41l4.48,4.48c0.67,0.68 1.81,0.2 1.81,-0.74 0,-0.28 -0.11,-0.55 -0.31,-0.75L3,12l3.69,-3.69c0.2,-0.2 0.31,-0.47 0.31,-0.75zM13,9V7.41c0,-0.89 -1.08,-1.34 -1.71,-0.71L6.7,11.29c-0.39,0.39 -0.39,1.02 0,1.41l4.59,4.59c0.63,0.63 1.71,0.18 1.71,-0.71V14.9c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
</vector>
@@ -16,7 +16,6 @@ object Conversations
fun NavGraphBuilder.conversationsScreen( fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit, onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onCreateChatClicked: () -> Unit, onCreateChatClicked: () -> Unit,
navController: NavController, navController: NavController,
) { ) {
@@ -27,7 +26,6 @@ fun NavGraphBuilder.conversationsScreen(
ConversationsRoute( ConversationsRoute(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked, onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
@@ -6,7 +6,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -40,7 +39,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -69,10 +67,8 @@ fun ConversationItem(
maxLines: Int, maxLines: Int,
isUserAccount: Boolean, isUserAccount: Boolean,
conversation: UiConversation, conversation: UiConversation,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
onPhotoClicked: (url: String) -> Unit
) { ) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState( val bottomStartCornerRadius by animateDpAsState(
@@ -154,12 +150,7 @@ fun ConversationItem(
contentDescription = "Avatar", contentDescription = "Avatar",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(CircleShape) .clip(CircleShape),
.clickable {
if (avatarImage is String) {
onPhotoClicked(avatarImage)
}
},
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut) placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
) )
} }
@@ -39,13 +39,10 @@ fun ConversationsList(
maxLines: Int, maxLines: Int,
modifier: Modifier, modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit, onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues, padding: PaddingValues
onPhotoClicked: (url: String) -> Unit
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val bottomPadding = LocalBottomPadding.current
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = state state = state
@@ -71,8 +68,7 @@ fun ConversationsList(
maxLines = maxLines, maxLines = maxLines,
isUserAccount = isUserAccount, isUserAccount = isUserAccount,
conversation = conversation, conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
onPhotoClicked = onPhotoClicked
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -85,7 +85,6 @@ import dev.meloda.fast.ui.R as UiR
fun ConversationsRoute( fun ConversationsRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit, onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit, onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel viewModel: ConversationsViewModel
) { ) {
@@ -107,7 +106,6 @@ fun ConversationsRoute(
onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh, onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked, onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset setScrollOffset = viewModel::setScrollOffset
@@ -135,7 +133,6 @@ fun ConversationsScreen(
onPaginationConditionsMet: () -> Unit = {}, onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {}, onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {}, onCreateChatButtonClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {} setScrollOffset: (Int) -> Unit = {}
@@ -356,8 +353,7 @@ fun ConversationsScreen(
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
onOptionClicked = onOptionClicked, onOptionClicked = onOptionClicked,
padding = padding, padding = padding
onPhotoClicked = onConversationPhotoClicked
) )
if (screenState.conversations.isEmpty()) { if (screenState.conversations.isEmpty()) {
@@ -1,6 +1,8 @@
package dev.meloda.fast.messageshistory package dev.meloda.fast.messageshistory
import android.content.Context
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
@@ -45,6 +47,7 @@ import kotlin.random.Random
interface MessagesHistoryViewModel { interface MessagesHistoryViewModel {
val screenState: StateFlow<MessagesHistoryScreenState> val screenState: StateFlow<MessagesHistoryScreenState>
val selectedMessages: StateFlow<List<Int>>
val baseError: StateFlow<BaseError?> val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>> val imagesToPreload: StateFlow<List<String>>
@@ -53,6 +56,7 @@ interface MessagesHistoryViewModel {
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
fun onCloseButtonClicked()
fun onRefresh() fun onRefresh()
fun onAttachmentButtonClicked() fun onAttachmentButtonClicked()
fun onMessageInputChanged(newText: TextFieldValue) fun onMessageInputChanged(newText: TextFieldValue)
@@ -60,19 +64,23 @@ interface MessagesHistoryViewModel {
fun onActionButtonClicked() fun onActionButtonClicked()
fun onPaginationConditionsMet() fun onPaginationConditionsMet()
fun onMessageClicked(messageId: Int)
fun onMessageLongClicked(messageId: Int)
} }
class MessagesHistoryViewModelImpl( class MessagesHistoryViewModelImpl(
private val applicationContext: Context,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val resourceProvider: ResourceProvider, private val resourceProvider: ResourceProvider,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase,
updatesParser: LongPollUpdatesParser, updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() { ) : MessagesHistoryViewModel, ViewModel() {
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY) override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
override val selectedMessages = MutableStateFlow<List<Int>>(emptyList())
override val baseError = MutableStateFlow<BaseError?>(null) override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList()) override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
@@ -104,6 +112,18 @@ class MessagesHistoryViewModelImpl(
) )
} }
override fun onCloseButtonClicked() {
screenState.setValue { old ->
old.copy(
messages = old.messages.map {
if (it is UiItem.Message) it.copy(isSelected = false)
else it
}
)
}
selectedMessages.setValue { emptyList() }
}
override fun onRefresh() { override fun onRefresh() {
loadMessagesHistory(offset = 0) loadMessagesHistory(offset = 0)
} }
@@ -158,6 +178,60 @@ class MessagesHistoryViewModelImpl(
loadMessagesHistory() loadMessagesHistory()
} }
override fun onMessageClicked(messageId: Int) {
val messageIndex = screenState.value.messages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
} ?: return
val newMessages = screenState.value.messages.toMutableList()
val currentMessage: UiItem.Message = newMessages[messageIndex] as UiItem.Message
if (selectedMessages.value.isNotEmpty()) {
val isSelected = selectedMessages.value.contains(currentMessage.id)
newMessages[messageIndex] = currentMessage.copy(
isSelected = !isSelected
)
screenState.setValue { old -> old.copy(messages = newMessages) }
selectedMessages.setValue { old ->
old.toMutableList().also {
if (isSelected) {
it.remove(currentMessage.id)
} else {
it.add(currentMessage.id)
}
}
}
} else {
Toast.makeText(applicationContext, "Click", Toast.LENGTH_SHORT).show()
}
}
override fun onMessageLongClicked(messageId: Int) {
val messageIndex = screenState.value.messages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
} ?: return
val newMessages = screenState.value.messages.toMutableList()
val currentMessage: UiItem.Message = newMessages[messageIndex] as UiItem.Message
val isSelected = selectedMessages.value.contains(currentMessage.id)
newMessages[messageIndex] = currentMessage.copy(
isSelected = !isSelected
)
screenState.setValue { old -> old.copy(messages = newMessages) }
selectedMessages.setValue { old ->
old.toMutableList().also {
if (isSelected) {
it.remove(currentMessage.id)
} else {
it.add(currentMessage.id)
}
}
}
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
@@ -24,7 +24,8 @@ sealed class UiItem(
val avatar: UiImage, val avatar: UiImage,
val isEdited: Boolean, val isEdited: Boolean,
val isRead: Boolean, val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT val sendingStatus: SendingStatus = SendingStatus.SENT,
val isSelected: Boolean = false
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
data class ActionMessage( data class ActionMessage(
@@ -31,60 +31,61 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun IncomingMessageBubble( fun IncomingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
animate: Boolean animate: Boolean,
) { ) {
val context = LocalContext.current Row(modifier = modifier.fillMaxWidth()) {
Row(
Row( modifier = Modifier
modifier = modifier .fillMaxWidth(0.75f)
.fillMaxWidth(0.75f) .padding(start = 16.dp),
.padding(start = 16.dp), verticalAlignment = Alignment.Bottom,
verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.Start
horizontalArrangement = Arrangement.Start ) {
) { if (message.isInChat) {
if (message.isInChat) { Image(
Image( painter =
painter = message.avatar.extractUrl()?.let { url ->
message.avatar.extractUrl()?.let { url -> rememberAsyncImagePainter(
rememberAsyncImagePainter( model = url,
model = url, imageLoader = LocalContext.current.imageLoader
imageLoader = context.imageLoader )
) } ?: painterResource(id = message.avatar.extractResId()),
} ?: painterResource(id = message.avatar.extractResId()), contentDescription = null,
contentDescription = null,
modifier = Modifier
.padding(bottom = 6.dp)
.size(28.dp)
.alpha(if (message.showAvatar) 1f else 0f)
.clip(CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
Column {
AnimatedVisibility(visible = message.showName) {
Text(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp) .padding(bottom = 6.dp)
.widthIn(max = 140.dp), .size(28.dp)
text = message.name, .alpha(if (message.showAvatar) 1f else 0f)
style = MaterialTheme.typography.bodySmall, .clip(CircleShape),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Spacer(modifier = Modifier.width(8.dp))
} }
MessageBubble( Column {
modifier = Modifier, AnimatedVisibility(visible = message.showName) {
text = message.text, Text(
isOut = false, modifier = Modifier
date = message.date, .padding(start = 12.dp)
edited = message.isEdited, .widthIn(max = 140.dp),
animate = animate, text = message.name,
isRead = message.isRead, style = MaterialTheme.typography.bodySmall,
sendingStatus = message.sendingStatus color = MaterialTheme.colorScheme.primary,
) maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
MessageBubble(
modifier = Modifier,
text = message.text,
isOut = false,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
}
} }
Spacer(modifier=Modifier.fillMaxWidth(0.25f))
} }
} }
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -33,6 +34,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -107,6 +109,7 @@ fun MessagesHistoryRoute(
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>() viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
@@ -115,10 +118,12 @@ fun MessagesHistoryRoute(
MessagesHistoryScreen( MessagesHistoryScreen(
screenState = screenState, screenState = screenState,
selectedMessages = ImmutableList.copyOf(selectedMessages),
baseError = baseError, baseError = baseError,
canPaginate = canPaginate, canPaginate = canPaginate,
showEmojiButton = showEmojiButton, showEmojiButton = showEmojiButton,
onBack = onBack, onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
@@ -126,7 +131,9 @@ fun MessagesHistoryRoute(
onMessageInputChanged = viewModel::onMessageInputChanged, onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked, onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked, onActionButtonClicked = viewModel::onActionButtonClicked,
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked
) )
} }
@@ -138,10 +145,12 @@ fun MessagesHistoryRoute(
@Composable @Composable
fun MessagesHistoryScreen( fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY, screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
selectedMessages: ImmutableList<Int> = ImmutableList.empty(),
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {}, onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> }, onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
@@ -149,7 +158,9 @@ fun MessagesHistoryScreen(
onMessageInputChanged: (TextFieldValue) -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {}, onActionButtonClicked: () -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {} onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
@@ -157,6 +168,11 @@ fun MessagesHistoryScreen(
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
BackHandler(
enabled = selectedMessages.isNotEmpty(),
onBack = onClose
)
val listState = rememberLazyListState() val listState = rememberLazyListState()
val paginationConditionMet by remember(canPaginate, listState) { val paginationConditionMet by remember(canPaginate, listState) {
@@ -191,6 +207,10 @@ fun MessagesHistoryScreen(
val density = LocalDensity.current val density = LocalDensity.current
val showReplyAction by remember(screenState) {
mutableStateOf(selectedMessages.size == 1)
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
@@ -213,32 +233,36 @@ fun MessagesHistoryScreen(
.weight(1f), .weight(1f),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val avatar = screenState.avatar.getImage() if (selectedMessages.isEmpty()) {
if (avatar is Painter) { val avatar = screenState.avatar.getImage()
Image( if (avatar is Painter) {
painter = avatar, Image(
contentDescription = null, painter = avatar,
modifier = Modifier contentDescription = null,
.size(36.dp) modifier = Modifier
.clip(CircleShape) .size(36.dp)
) .clip(CircleShape)
} else { )
AsyncImage( } else {
model = screenState.avatar.getImage(), AsyncImage(
contentDescription = "Profile Image", model = screenState.avatar.getImage(),
modifier = Modifier contentDescription = "Profile Image",
.size(36.dp) modifier = Modifier
.clip(CircleShape), .size(36.dp)
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut), .clip(CircleShape),
) placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
)
}
Spacer(modifier = Modifier.width(12.dp))
} }
Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = text = when {
if (screenState.isLoading) stringResource(id = UiR.string.title_loading) screenState.isLoading -> stringResource(id = UiR.string.title_loading)
else screenState.title, selectedMessages.size > 0 -> "(${selectedMessages.size})"
else -> screenState.title
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
@@ -246,9 +270,18 @@ fun MessagesHistoryScreen(
} }
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(
onClick = {
if (selectedMessages.isEmpty()) onBack()
else onClose()
}
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = if (selectedMessages.isEmpty()) {
Icons.AutoMirrored.Rounded.ArrowBack
} else {
Icons.Rounded.Close
},
contentDescription = "Back button" contentDescription = "Back button"
) )
} }
@@ -259,54 +292,95 @@ fun MessagesHistoryScreen(
) )
), ),
actions = { actions = {
IconButton( if (selectedMessages.isNotEmpty()) {
onClick = { dropDownMenuExpanded = true } AnimatedVisibility(showReplyAction) {
) { IconButton(
Icon( onClick = {
imageVector = Icons.Outlined.MoreVert, if (AppSettings.General.enableHaptic) {
contentDescription = "Options" view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
) }
} }
) {
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
dropDownMenuExpanded = false
// TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId
)
},
text = {
Text(text = "Materials")
}
)
DropdownMenuItem(
onClick = {
onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = "Refresh")
},
leadingIcon = {
Icon( Icon(
imageVector = Icons.Rounded.Refresh, painter = painterResource(UiR.drawable.round_reply_24),
contentDescription = null contentDescription = null
) )
} }
) }
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_reply_all_24),
contentDescription = null
)
}
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_delete_outline_24),
contentDescription = null
)
}
} else {
IconButton(
onClick = { dropDownMenuExpanded = true }
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
dropDownMenuExpanded = false
// TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId
)
},
text = {
Text(text = "Materials")
}
)
DropdownMenuItem(
onClick = {
onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = "Refresh")
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
}
} }
} }
) )
@@ -323,7 +397,6 @@ fun MessagesHistoryScreen(
} }
} }
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -343,7 +416,16 @@ fun MessagesHistoryScreen(
index = screenState.messages.indexOfMessageByCmId(cmId) index = screenState.messages.indexOfMessageByCmId(cmId)
) )
} }
} },
onMessageClicked = { id ->
if (selectedMessages.isNotEmpty()) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
}
}
onMessageClicked(id)
},
onMessageLongClicked = onMessageLongClicked
) )
Column( Column(
@@ -1,30 +1,41 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
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
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun MessagesList( fun MessagesList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -33,7 +44,9 @@ fun MessagesList(
immutableMessages: ImmutableList<UiItem>, immutableMessages: ImmutableList<UiItem>,
isPaginating: Boolean, isPaginating: Boolean,
messageBarHeight: Dp, messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {} onRequestScrollToCmId: (cmId: Int) -> Unit = {},
onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {}
) { ) {
val enableAnimations = remember { val enableAnimations = remember {
AppSettings.Experimental.moreAnimations AppSettings.Experimental.moreAnimations
@@ -42,13 +55,14 @@ fun MessagesList(
immutableMessages.toList() immutableMessages.toList()
} }
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val view = LocalView.current
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then( .then(
if (currentTheme.enableBlur) { if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState) Modifier.hazeSource(state = hazeState)
} else Modifier } else Modifier
), ),
state = listState, state = listState,
@@ -87,37 +101,61 @@ fun MessagesList(
} }
is UiItem.Message -> { is UiItem.Message -> {
if (item.isOut) { val backgroundColor by animateColorAsState(
OutgoingMessageBubble( targetValue = if (item.isSelected) {
modifier = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
Modifier.then( } else {
if (enableAnimations) Modifier.animateItem( Color.Transparent
fadeInSpec = null, }
fadeOutSpec = null )
)
else Modifier Surface(
), modifier = Modifier
message = item, .combinedClickable(
animate = enableAnimations onLongClick = {
) if (AppSettings.General.enableHaptic) {
} else { view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
IncomingMessageBubble( }
modifier = onMessageLongClicked(item.id)
Modifier.then( },
if (enableAnimations) Modifier.animateItem( onClick = { onMessageClicked(item.id) }
fadeInSpec = null, ),
fadeOutSpec = null color = backgroundColor
) ) {
else Modifier if (item.isOut) {
), OutgoingMessageBubble(
message = item, modifier =
animate = enableAnimations Modifier
) .padding(vertical = 4.dp)
.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
} else {
IncomingMessageBubble(
modifier =
Modifier
.padding(vertical = 4.dp)
.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
}
} }
} }
} }
Spacer(modifier = Modifier.height(8.dp))
} }
item { item {