From e293b0a203c6245047b247601fc8f1d63250e027 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 21 Nov 2022 12:42:42 +0100 Subject: [PATCH] Add long click support on timeline item --- .../x/features/messages/MessagesScreen.kt | 70 +++++++++++++++---- .../x/features/messages/MessagesViewModel.kt | 26 +++++++ .../MessagesTimelineItemActionsSheet.kt | 59 ++++++++++++++++ .../messages/model/MessagesItemAction.kt | 11 +++ .../model/MessagesItemActionsSheetState.kt | 9 +++ .../model/MessagesItemReactionState.kt | 1 - .../messages/model/MessagesViewState.kt | 1 + .../element/android/x/designsystem/Icons.kt | 8 +++ .../x/designsystem/components/VectorIcon.kt | 22 ++++++ .../res/drawable/ic_content_arrow_forward.xml | 5 ++ .../src/main/res/drawable/ic_content_copy.xml | 5 ++ .../io.element.android-compose.gradle.kts | 1 + 12 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt create mode 100644 libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt create mode 100644 libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/VectorIcon.kt create mode 100644 libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml create mode 100644 libraries/designsystem/src/main/res/drawable/ic_content_copy.xml diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt index 73d4183a2e..27cfe62697 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt @@ -1,10 +1,11 @@ -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) package io.element.android.x.features.messages import Avatar import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -12,14 +13,14 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.End import androidx.compose.ui.Alignment.Companion.Start @@ -49,6 +50,8 @@ import io.element.android.x.features.messages.model.content.MessagesTimelineItem import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel import io.element.android.x.features.messages.textcomposer.MessageComposerViewState import io.element.android.x.textcomposer.TextComposer +import kotlinx.coroutines.launch +import timber.log.Timber private val BUBBLE_RADIUS = 16.dp private val COMPOSER_HEIGHT = 112.dp @@ -61,6 +64,10 @@ fun MessagesScreen( val viewModel: MessagesViewModel = mavericksViewModel(argsFactory = { roomId }) val composerViewModel: MessageComposerViewModel = mavericksViewModel(argsFactory = { roomId }) LogCompositions(tag = "MessagesScreen", msg = "Root") + val actionsSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + ) + val coroutineScope = rememberCoroutineScope() val roomTitle by viewModel.collectAsState(MessagesViewState::roomName) val roomAvatar by viewModel.collectAsState(MessagesViewState::roomAvatar) val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems) @@ -84,6 +91,21 @@ fun MessagesScreen( onComposerTextChange = composerViewModel::updateText, composerCanSendMessage = composerCanSendMessage, composerText = composerText, + onClick = { + Timber.v("onClick on timeline item: ${it.id}") + }, + onLongClick = { + viewModel.computeActionsSheetState(it) + coroutineScope.launch { + actionsSheetState.show() + } + } + ) + val itemActionsSheetState by viewModel.collectAsState(prop1 = MessagesViewState::itemActionsSheetState) + TimelineItemActionsScreen( + sheetState = actionsSheetState, + actionsSheetState = itemActionsSheetState(), + onActionClicked = viewModel::handleItemAction ) } @@ -96,6 +118,8 @@ fun MessagesContent( onReachedLoadMore: () -> Unit, onBackPressed: () -> Unit, onSendMessage: (String) -> Unit, + onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, + onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit, composerFullScreen: Boolean, onComposerFullScreenChange: () -> Unit, onComposerTextChange: (CharSequence) -> Unit, @@ -145,7 +169,9 @@ fun MessagesContent( timelineItems = timelineItems, hasMoreToLoad = hasMoreToLoad, onReachedLoadMore = onReachedLoadMore, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onClick = onClick, + onLongClick = onLongClick ) } TextComposer( @@ -175,6 +201,8 @@ fun TimelineItems( lazyListState: LazyListState, timelineItems: List, hasMoreToLoad: Boolean, + onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, + onLongClick: ((MessagesTimelineItemState.MessageEvent)) -> Unit, onReachedLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { @@ -186,7 +214,11 @@ fun TimelineItems( reverseLayout = true ) { itemsIndexed(timelineItems) { index, timelineItem -> - TimelineItemRow(timelineItem = timelineItem) + TimelineItemRow( + timelineItem = timelineItem, + onClick = onClick, + onLongClick = onLongClick + ) } if (hasMoreToLoad) { item { @@ -199,17 +231,25 @@ fun TimelineItems( @Composable fun TimelineItemRow( - timelineItem: MessagesTimelineItemState + timelineItem: MessagesTimelineItemState, + onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, + onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit, ) { when (timelineItem) { is MessagesTimelineItemState.Virtual -> return - is MessagesTimelineItemState.MessageEvent -> MessageEventRow(messageEvent = timelineItem) + is MessagesTimelineItemState.MessageEvent -> MessageEventRow( + messageEvent = timelineItem, + onClick = { onClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) } + ) } } @Composable fun MessageEventRow( messageEvent: MessagesTimelineItemState.MessageEvent, + onClick: () -> Unit, + onLongClick: () -> Unit, modifier: Modifier = Modifier ) { val (parentAlignment, contentAlignment) = if (messageEvent.isMine) { @@ -241,6 +281,8 @@ fun MessageEventRow( MessageEventBubble( groupPosition = messageEvent.groupPosition, isMine = messageEvent.isMine, + onClick = onClick, + onLongClick = onLongClick, modifier = Modifier .zIndex(-1f) ) { @@ -304,11 +346,14 @@ private fun MessageSenderInformation( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MessageEventBubble( groupPosition: MessagesItemGroupPosition, isMine: Boolean, modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, content: @Composable () -> Unit, ) { fun bubbleShape(): Shape { @@ -360,8 +405,9 @@ fun MessageEventBubble( .widthIn(min = 80.dp) .offsetForItem() .clip(bubbleShape) - .clickable( - onClick = { }, + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() } ), diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt index a71d8ac037..b1361ba15a 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt @@ -5,6 +5,9 @@ import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ViewModelContext import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.designsystem.components.avatar.AvatarSize +import io.element.android.x.features.messages.model.MessagesItemAction +import io.element.android.x.features.messages.model.MessagesItemActionsSheetState +import io.element.android.x.features.messages.model.MessagesTimelineItemState import io.element.android.x.features.messages.model.MessagesViewState import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixInstance @@ -16,6 +19,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import timber.log.Timber private const val PAGINATION_COUNT = 50 @@ -66,6 +70,28 @@ class MessagesViewModel( } } + fun handleItemAction(action: MessagesItemAction) { + viewModelScope.launch(Dispatchers.Default) { + val currentState = awaitState() + Timber.v("Handle $action for ${currentState.itemActionsSheetState}") + } + } + + fun computeActionsSheetState(messagesTimelineItemState: MessagesTimelineItemState.MessageEvent) { + suspend { + val actions = listOf( + MessagesItemAction.Forward, + MessagesItemAction.Copy, + ) + MessagesItemActionsSheetState( + targetItem = messagesTimelineItemState, + actions = actions + ) + }.execute(Dispatchers.Default) { + copy(itemActionsSheetState = it) + } + } + private fun handleInit() { timeline.initialize() room.syncUpdateFlow() diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt new file mode 100644 index 0000000000..bdf497700b --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemActionsSheet.kt @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalMaterialApi::class) + +package io.element.android.x.features.messages.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.x.designsystem.components.VectorIcon +import io.element.android.x.features.messages.model.MessagesItemAction +import io.element.android.x.features.messages.model.MessagesItemActionsSheetState + +@Composable +fun TimelineItemActionsScreen( + sheetState: ModalBottomSheetState, + actionsSheetState: MessagesItemActionsSheetState?, + onActionClicked: (MessagesItemAction) -> Unit, + modifier: Modifier = Modifier +) { + ModalBottomSheetLayout( + modifier = modifier, + sheetState = sheetState, + sheetContent = { + SheetContent( + actionsSheetState = actionsSheetState, + onActionClicked = onActionClicked + ) + } + ) {} + +} + +@Composable +private fun SheetContent( + actionsSheetState: MessagesItemActionsSheetState?, + onActionClicked: (MessagesItemAction) -> Unit, +) { + if (actionsSheetState == null || actionsSheetState.actions.isEmpty()) { + // Crashes if sheetContent size is zero + Box(modifier = Modifier.size(1.dp)) + return + } + LazyColumn { + items(actionsSheetState.actions) { + ListItem( + modifier = Modifier.clickable { + onActionClicked(it) + }, + text = { Text(it.title) }, + icon = { VectorIcon(it.icon) } + ) + } + } +} \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt new file mode 100644 index 0000000000..5bd90c1a8c --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt @@ -0,0 +1,11 @@ +package io.element.android.x.features.messages.model + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Stable +import io.element.android.x.designsystem.VectorIcons + +@Stable +sealed class MessagesItemAction(val title: String, @DrawableRes val icon: Int) { + object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward) + object Copy : MessagesItemAction("Copy", VectorIcons.Copy) +} \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt new file mode 100644 index 0000000000..74aa7dbd56 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemActionsSheetState.kt @@ -0,0 +1,9 @@ +package io.element.android.x.features.messages.model + +import androidx.compose.runtime.Stable + +@Stable +data class MessagesItemActionsSheetState( + val targetItem: MessagesTimelineItemState.MessageEvent, + val actions: List +) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt index 9e22dda284..fc97f1d862 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemReactionState.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Stable data class MessagesItemReactionState( val reactions: List ) - @Stable data class AggregatedReaction( val key: String, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt index 031775552c..4e18587dcc 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt @@ -11,6 +11,7 @@ data class MessagesViewState( val roomAvatar: AvatarData? = null, val timelineItems: Async> = Uninitialized, val hasMoreToLoad: Boolean = true, + val itemActionsSheetState: Async = Uninitialized ) : MavericksState { @Suppress("unused") diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt new file mode 100644 index 0000000000..77b2600efb --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt @@ -0,0 +1,8 @@ +package io.element.android.x.designsystem + +import io.element.android.x.libraries.designsystem.R + +object VectorIcons { + val Copy = R.drawable.ic_content_copy + val ArrowForward = R.drawable.ic_content_arrow_forward +} \ No newline at end of file diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/VectorIcon.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/VectorIcon.kt new file mode 100644 index 0000000000..2726ba0fd1 --- /dev/null +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/components/VectorIcon.kt @@ -0,0 +1,22 @@ +package io.element.android.x.designsystem.components + +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource + +@Composable +fun VectorIcon( + resourceId: Int, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + Icon( + painter = painterResource(id = resourceId), + contentDescription = null, + modifier = modifier, + tint = tint + ) +} diff --git a/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml b/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml new file mode 100644 index 0000000000..8d3848e661 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml @@ -0,0 +1,5 @@ + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_content_copy.xml b/libraries/designsystem/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 0000000000..bac0f6001a --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,5 @@ + + + diff --git a/plugins/src/main/java/io.element.android-compose.gradle.kts b/plugins/src/main/java/io.element.android-compose.gradle.kts index 1f1ca3861f..d9544bd098 100644 --- a/plugins/src/main/java/io.element.android-compose.gradle.kts +++ b/plugins/src/main/java/io.element.android-compose.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(platform("androidx.compose:compose-bom:2022.10.00")) implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material:material") implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1")