diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt index 27286c74..2d375f6d 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/MainScreen.kt @@ -170,7 +170,6 @@ fun MainScreen( conversationsScreen( onError = onError, onConversationItemClicked = onConversationItemClicked, - onPhotoClicked = onPhotoClicked, onCreateChatClicked = onCreateChatClicked, navController = navController, ) diff --git a/core/ui/src/main/res/drawable/round_reply_24.xml b/core/ui/src/main/res/drawable/round_reply_24.xml new file mode 100644 index 00000000..8f412d6f --- /dev/null +++ b/core/ui/src/main/res/drawable/round_reply_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_reply_all_24.xml b/core/ui/src/main/res/drawable/round_reply_all_24.xml new file mode 100644 index 00000000..921da1f8 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_reply_all_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt index 0cf05c68..63876d0e 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/navigation/ConversationsNavigation.kt @@ -16,7 +16,6 @@ object Conversations fun NavGraphBuilder.conversationsScreen( onError: (BaseError) -> Unit, onConversationItemClicked: (id: Int) -> Unit, - onPhotoClicked: (url: String) -> Unit, onCreateChatClicked: () -> Unit, navController: NavController, ) { @@ -27,7 +26,6 @@ fun NavGraphBuilder.conversationsScreen( ConversationsRoute( onError = onError, onConversationItemClicked = onConversationItemClicked, - onConversationPhotoClicked = onPhotoClicked, onCreateChatButtonClicked = onCreateChatClicked, viewModel = viewModel ) diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt index ed0751f0..81ee7f02 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationItem.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll 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.painter.Painter import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString @@ -69,10 +67,8 @@ fun ConversationItem( maxLines: Int, isUserAccount: Boolean, conversation: UiConversation, - modifier: Modifier = Modifier, - onPhotoClicked: (url: String) -> Unit + modifier: Modifier = Modifier ) { - val context = LocalContext.current val hapticFeedback = LocalHapticFeedback.current val bottomStartCornerRadius by animateDpAsState( @@ -154,12 +150,7 @@ fun ConversationItem( contentDescription = "Avatar", modifier = Modifier .fillMaxSize() - .clip(CircleShape) - .clickable { - if (avatarImage is String) { - onPhotoClicked(avatarImage) - } - }, + .clip(CircleShape), placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut) ) } diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt index 666827c6..f889911c 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsList.kt @@ -39,13 +39,10 @@ fun ConversationsList( maxLines: Int, modifier: Modifier, onOptionClicked: (UiConversation, ConversationOption) -> Unit, - padding: PaddingValues, - onPhotoClicked: (url: String) -> Unit + padding: PaddingValues ) { val coroutineScope = rememberCoroutineScope() - val bottomPadding = LocalBottomPadding.current - LazyColumn( modifier = modifier, state = state @@ -71,8 +68,7 @@ fun ConversationsList( maxLines = maxLines, isUserAccount = isUserAccount, conversation = conversation, - modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), - onPhotoClicked = onPhotoClicked + modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index 863fafef..b7e1a75f 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -85,7 +85,6 @@ import dev.meloda.fast.ui.R as UiR fun ConversationsRoute( onError: (BaseError) -> Unit, onConversationItemClicked: (conversationId: Int) -> Unit, - onConversationPhotoClicked: (url: String) -> Unit, onCreateChatButtonClicked: () -> Unit, viewModel: ConversationsViewModel ) { @@ -107,7 +106,6 @@ fun ConversationsRoute( onPaginationConditionsMet = viewModel::onPaginationConditionsMet, onRefreshDropdownItemClicked = viewModel::onRefresh, onRefresh = viewModel::onRefresh, - onConversationPhotoClicked = onConversationPhotoClicked, onCreateChatButtonClicked = onCreateChatButtonClicked, setScrollIndex = viewModel::setScrollIndex, setScrollOffset = viewModel::setScrollOffset @@ -135,7 +133,6 @@ fun ConversationsScreen( onPaginationConditionsMet: () -> Unit = {}, onRefreshDropdownItemClicked: () -> Unit = {}, onRefresh: () -> Unit = {}, - onConversationPhotoClicked: (url: String) -> Unit = {}, onCreateChatButtonClicked: () -> Unit = {}, setScrollIndex: (Int) -> Unit = {}, setScrollOffset: (Int) -> Unit = {} @@ -356,8 +353,7 @@ fun ConversationsScreen( Modifier }.fillMaxSize(), onOptionClicked = onOptionClicked, - padding = padding, - onPhotoClicked = onConversationPhotoClicked + padding = padding ) if (screenState.conversations.isEmpty()) { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index 345830e5..2c6af89c 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -1,6 +1,8 @@ package dev.meloda.fast.messageshistory +import android.content.Context import android.util.Log +import android.widget.Toast import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.SavedStateHandle @@ -45,6 +47,7 @@ import kotlin.random.Random interface MessagesHistoryViewModel { val screenState: StateFlow + val selectedMessages: StateFlow> val baseError: StateFlow val imagesToPreload: StateFlow> @@ -53,6 +56,7 @@ interface MessagesHistoryViewModel { val canPaginate: StateFlow + fun onCloseButtonClicked() fun onRefresh() fun onAttachmentButtonClicked() fun onMessageInputChanged(newText: TextFieldValue) @@ -60,19 +64,23 @@ interface MessagesHistoryViewModel { fun onActionButtonClicked() fun onPaginationConditionsMet() + + fun onMessageClicked(messageId: Int) + fun onMessageLongClicked(messageId: Int) } class MessagesHistoryViewModelImpl( + private val applicationContext: Context, private val messagesUseCase: MessagesUseCase, private val conversationsUseCase: ConversationsUseCase, private val resourceProvider: ResourceProvider, private val userSettings: UserSettings, - private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase, updatesParser: LongPollUpdatesParser, savedStateHandle: SavedStateHandle ) : MessagesHistoryViewModel, ViewModel() { override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY) + override val selectedMessages = MutableStateFlow>(emptyList()) override val baseError = MutableStateFlow(null) override val imagesToPreload = MutableStateFlow>(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() { loadMessagesHistory(offset = 0) } @@ -158,6 +178,60 @@ class MessagesHistoryViewModelImpl( 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) { val message = event.message diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt index 1eadb2d0..a7013fc1 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt @@ -24,7 +24,8 @@ sealed class UiItem( val avatar: UiImage, val isEdited: Boolean, val isRead: Boolean, - val sendingStatus: SendingStatus = SendingStatus.SENT + val sendingStatus: SendingStatus = SendingStatus.SENT, + val isSelected: Boolean = false ) : UiItem(id, conversationMessageId) data class ActionMessage( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt index d01bdef3..dd1d960a 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt @@ -31,60 +31,61 @@ import dev.meloda.fast.messageshistory.model.UiItem fun IncomingMessageBubble( modifier: Modifier = Modifier, message: UiItem.Message, - animate: Boolean + animate: Boolean, ) { - val context = LocalContext.current - - Row( - modifier = modifier - .fillMaxWidth(0.75f) - .padding(start = 16.dp), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.Start - ) { - if (message.isInChat) { - Image( - painter = - message.avatar.extractUrl()?.let { url -> - rememberAsyncImagePainter( - model = url, - imageLoader = context.imageLoader - ) - } ?: painterResource(id = message.avatar.extractResId()), - 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( + Row(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth(0.75f) + .padding(start = 16.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Start + ) { + if (message.isInChat) { + Image( + painter = + message.avatar.extractUrl()?.let { url -> + rememberAsyncImagePainter( + model = url, + imageLoader = LocalContext.current.imageLoader + ) + } ?: painterResource(id = message.avatar.extractResId()), + contentDescription = null, modifier = Modifier - .padding(start = 12.dp) - .widthIn(max = 140.dp), - text = message.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + .padding(bottom = 6.dp) + .size(28.dp) + .alpha(if (message.showAvatar) 1f else 0f) + .clip(CircleShape), ) + Spacer(modifier = Modifier.width(8.dp)) } - MessageBubble( - modifier = Modifier, - text = message.text, - isOut = false, - date = message.date, - edited = message.isEdited, - animate = animate, - isRead = message.isRead, - sendingStatus = message.sendingStatus - ) + Column { + AnimatedVisibility(visible = message.showName) { + Text( + modifier = Modifier + .padding(start = 12.dp) + .widthIn(max = 140.dp), + text = message.name, + style = MaterialTheme.typography.bodySmall, + 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)) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index b599ce3c..c7c67c84 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.messageshistory.presentation +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize 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.automirrored.rounded.ArrowBack import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -107,6 +109,7 @@ fun MessagesHistoryRoute( viewModel: MessagesHistoryViewModel = koinViewModel() ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() @@ -115,10 +118,12 @@ fun MessagesHistoryRoute( MessagesHistoryScreen( screenState = screenState, + selectedMessages = ImmutableList.copyOf(selectedMessages), baseError = baseError, canPaginate = canPaginate, showEmojiButton = showEmojiButton, onBack = onBack, + onClose = viewModel::onCloseButtonClicked, onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, onRefresh = viewModel::onRefresh, @@ -126,7 +131,9 @@ fun MessagesHistoryRoute( onMessageInputChanged = viewModel::onMessageInputChanged, onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked, onActionButtonClicked = viewModel::onActionButtonClicked, - onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked + onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked, + onMessageClicked = viewModel::onMessageClicked, + onMessageLongClicked = viewModel::onMessageLongClicked ) } @@ -138,10 +145,12 @@ fun MessagesHistoryRoute( @Composable fun MessagesHistoryScreen( screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY, + selectedMessages: ImmutableList = ImmutableList.empty(), baseError: BaseError? = null, canPaginate: Boolean = false, showEmojiButton: Boolean = false, onBack: () -> Unit = {}, + onClose: () -> Unit = {}, onSessionExpiredLogOutButtonClicked: () -> Unit = {}, onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> }, onRefresh: () -> Unit = {}, @@ -149,7 +158,9 @@ fun MessagesHistoryScreen( onMessageInputChanged: (TextFieldValue) -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {}, onActionButtonClicked: () -> Unit = {}, - onEmojiButtonLongClicked: () -> Unit = {} + onEmojiButtonLongClicked: () -> Unit = {}, + onMessageClicked: (Int) -> Unit = {}, + onMessageLongClicked: (Int) -> Unit = {} ) { val view = LocalView.current @@ -157,6 +168,11 @@ fun MessagesHistoryScreen( val currentTheme = LocalThemeConfig.current + BackHandler( + enabled = selectedMessages.isNotEmpty(), + onBack = onClose + ) + val listState = rememberLazyListState() val paginationConditionMet by remember(canPaginate, listState) { @@ -191,6 +207,10 @@ fun MessagesHistoryScreen( val density = LocalDensity.current + val showReplyAction by remember(screenState) { + mutableStateOf(selectedMessages.size == 1) + } + Scaffold( modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets.statusBars, @@ -213,32 +233,36 @@ fun MessagesHistoryScreen( .weight(1f), verticalAlignment = Alignment.CenterVertically ) { - val avatar = screenState.avatar.getImage() - if (avatar is Painter) { - Image( - painter = avatar, - contentDescription = null, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - ) - } else { - AsyncImage( - model = screenState.avatar.getImage(), - contentDescription = "Profile Image", - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut), - ) + if (selectedMessages.isEmpty()) { + val avatar = screenState.avatar.getImage() + if (avatar is Painter) { + Image( + painter = avatar, + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + } else { + AsyncImage( + model = screenState.avatar.getImage(), + contentDescription = "Profile Image", + modifier = Modifier + .size(36.dp) + .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 = - if (screenState.isLoading) stringResource(id = UiR.string.title_loading) - else screenState.title, + text = when { + screenState.isLoading -> stringResource(id = UiR.string.title_loading) + selectedMessages.size > 0 -> "(${selectedMessages.size})" + else -> screenState.title + }, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineSmall @@ -246,9 +270,18 @@ fun MessagesHistoryScreen( } }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton( + onClick = { + if (selectedMessages.isEmpty()) onBack() + else onClose() + } + ) { Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + imageVector = if (selectedMessages.isEmpty()) { + Icons.AutoMirrored.Rounded.ArrowBack + } else { + Icons.Rounded.Close + }, contentDescription = "Back button" ) } @@ -259,54 +292,95 @@ fun MessagesHistoryScreen( ) ), actions = { - 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 = { + if (selectedMessages.isNotEmpty()) { + AnimatedVisibility(showReplyAction) { + IconButton( + onClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT) + } + } + ) { Icon( - imageVector = Icons.Rounded.Refresh, + painter = painterResource(UiR.drawable.round_reply_24), 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 -> - Box( modifier = Modifier .fillMaxSize() @@ -343,7 +416,16 @@ fun MessagesHistoryScreen( index = screenState.messages.indexOfMessageByCmId(cmId) ) } - } + }, + onMessageClicked = { id -> + if (selectedMessages.isNotEmpty()) { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK) + } + } + onMessageClicked(id) + }, + onMessageLongClicked = onMessageLongClicked ) Column( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index ad9b7c0e..8da688dd 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -1,30 +1,41 @@ package dev.meloda.fast.messageshistory.presentation +import android.view.HapticFeedbackConstants 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment 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 dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.haze +import dev.chrisbanes.haze.hazeSource import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +@OptIn(ExperimentalFoundationApi::class) @Composable fun MessagesList( modifier: Modifier = Modifier, @@ -33,7 +44,9 @@ fun MessagesList( immutableMessages: ImmutableList, isPaginating: Boolean, messageBarHeight: Dp, - onRequestScrollToCmId: (cmId: Int) -> Unit = {} + onRequestScrollToCmId: (cmId: Int) -> Unit = {}, + onMessageClicked: (Int) -> Unit = {}, + onMessageLongClicked: (Int) -> Unit = {} ) { val enableAnimations = remember { AppSettings.Experimental.moreAnimations @@ -42,13 +55,14 @@ fun MessagesList( immutableMessages.toList() } val currentTheme = LocalThemeConfig.current + val view = LocalView.current LazyColumn( modifier = modifier .fillMaxWidth() .then( if (currentTheme.enableBlur) { - Modifier.haze(state = hazeState) + Modifier.hazeSource(state = hazeState) } else Modifier ), state = listState, @@ -87,37 +101,61 @@ fun MessagesList( } is UiItem.Message -> { - if (item.isOut) { - OutgoingMessageBubble( - modifier = - Modifier.then( - if (enableAnimations) Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) - else Modifier - ), - message = item, - animate = enableAnimations - ) - } else { - IncomingMessageBubble( - modifier = - Modifier.then( - if (enableAnimations) Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) - else Modifier - ), - message = item, - animate = enableAnimations - ) + val backgroundColor by animateColorAsState( + targetValue = if (item.isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + } else { + Color.Transparent + } + ) + + Surface( + modifier = Modifier + .combinedClickable( + onLongClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + onMessageLongClicked(item.id) + }, + onClick = { onMessageClicked(item.id) } + ), + color = backgroundColor + ) { + if (item.isOut) { + OutgoingMessageBubble( + modifier = + 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 {