diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt index 99ab9485f9..d430e8f0df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt @@ -50,6 +50,7 @@ import org.signal.core.ui.compose.DropdownMenus import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem import org.signal.core.ui.compose.copied.androidx.compose.dragContainer import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState @@ -121,10 +122,11 @@ fun FoldersScreen( val screenWidth = LocalConfiguration.current.screenWidthDp.dp val isRtl = ViewUtil.isRtl(LocalContext.current) val listState = rememberLazyListState() - val dragDropState = - rememberDragDropState(listState, includeHeader = true, includeFooter = true) { fromIndex, toIndex -> - onPositionUpdated(fromIndex, toIndex) + val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true) { event -> + if (event is DragAndDropEvent.OnItemMove) { + onPositionUpdated(event.fromIndex, event.toIndex) } + } LaunchedEffect(Unit) { if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt index c68ca0d9b2..2fb65e841f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt @@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,10 +33,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -45,6 +51,10 @@ import org.signal.core.ui.compose.Dividers import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent +import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem +import org.signal.core.ui.compose.copied.androidx.compose.dragContainer +import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R @@ -78,7 +88,18 @@ class StickerManagementActivityV2 : PassphraseRequiredActivity() { StickerManagementScreen( uiState = uiState, onNavigateBack = ::supportFinishAfterTransition, - onInstallClick = viewModel::installStickerPack + availableTabCallbacks = object : AvailableStickersContentCallbacks { + override fun onInstallClick(pack: AvailableStickerPack) = viewModel.installStickerPack(pack) + }, + installedTabCallbacks = object : InstalledStickersContentCallbacks { + override fun onDragAndDropEvent(event: DragAndDropEvent) { + when (event) { + is DragAndDropEvent.OnItemMove -> viewModel.updatePosition(event.fromIndex, event.toIndex) + is DragAndDropEvent.OnItemDrop -> viewModel.saveInstalledPacksSortOrder() + is DragAndDropEvent.OnDragCancel -> {} + } + } + } ) } } @@ -90,12 +111,29 @@ private data class Page( val getContent: @Composable () -> Unit ) +interface AvailableStickersContentCallbacks { + fun onInstallClick(pack: AvailableStickerPack) + + object Empty : AvailableStickersContentCallbacks { + override fun onInstallClick(pack: AvailableStickerPack) = Unit + } +} + +interface InstalledStickersContentCallbacks { + fun onDragAndDropEvent(event: DragAndDropEvent) + + object Empty : InstalledStickersContentCallbacks { + override fun onDragAndDropEvent(event: DragAndDropEvent) = Unit + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StickerManagementScreen( uiState: StickerManagementUiState, onNavigateBack: () -> Unit = {}, - onInstallClick: (AvailableStickerPack) -> Unit = {}, + availableTabCallbacks: AvailableStickersContentCallbacks = AvailableStickersContentCallbacks.Empty, + installedTabCallbacks: InstalledStickersContentCallbacks = InstalledStickersContentCallbacks.Empty, modifier: Modifier = Modifier ) { Scaffold( @@ -109,13 +147,18 @@ private fun StickerManagementScreen( AvailableStickersContent( blessedPacks = uiState.availableBlessedPacks, notBlessedPacks = uiState.availableNotBlessedPacks, - onInstallClick = onInstallClick + callbacks = availableTabCallbacks ) } ), Page( title = stringResource(R.string.StickerManagement_installed_tab_label), - getContent = { InstalledStickersContent(uiState.installedPacks) } + getContent = { + InstalledStickersContent( + packs = uiState.installedPacks, + callbacks = installedTabCallbacks + ) + } ) ) @@ -193,7 +236,7 @@ private fun PagerTab( private fun AvailableStickersContent( blessedPacks: List, notBlessedPacks: List, - onInstallClick: (AvailableStickerPack) -> Unit = {}, + callbacks: AvailableStickersContentCallbacks = AvailableStickersContentCallbacks.Empty, modifier: Modifier = Modifier ) { if (blessedPacks.isEmpty() && notBlessedPacks.isEmpty()) { @@ -211,7 +254,7 @@ private fun AvailableStickersContent( ) { AvailableStickerPackRow( pack = it, - onInstallClick = { onInstallClick(it) }, + onInstallClick = { callbacks.onInstallClick(it) }, modifier = Modifier.animateItem() ) } @@ -229,7 +272,7 @@ private fun AvailableStickersContent( ) { AvailableStickerPackRow( pack = it, - onInstallClick = { onInstallClick(it) }, + onInstallClick = { callbacks.onInstallClick(it) }, modifier = Modifier.animateItem() ) } @@ -241,21 +284,48 @@ private fun AvailableStickersContent( @Composable private fun InstalledStickersContent( packs: List, + callbacks: InstalledStickersContentCallbacks = InstalledStickersContentCallbacks.Empty, modifier: Modifier = Modifier ) { if (packs.isEmpty()) { EmptyView(text = stringResource(R.string.StickerManagement_installed_tab_empty_text)) } else { + val listState = rememberLazyListState() + val dragDropState = rememberDragDropState(lazyListState = listState, includeHeader = true, includeFooter = false, onEvent = callbacks::onDragAndDropEvent) + + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + LazyColumn( contentPadding = PaddingValues(top = 8.dp), - modifier = modifier.fillMaxHeight() + state = listState, + modifier = modifier + .fillMaxHeight() + .dragContainer( + dragDropState = dragDropState, + leftDpOffset = if (isRtl) 0.dp else screenWidth - 56.dp, + rightDpOffset = if (isRtl) 56.dp else screenWidth + ) ) { - item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_installed_stickers_header)) } - items( + item { + DraggableItem(dragDropState, 0) { + StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_installed_stickers_header)) + } + } + + itemsIndexed( items = packs, - key = { it.id.value } - ) { - InstalledStickerPackRow(it) + key = { _, pack -> pack.id.value } + ) { index, pack -> + DraggableItem( + index = index + 1, + dragDropState = dragDropState + ) { isDragging -> + InstalledStickerPackRow( + pack = pack, + modifier = Modifier.shadow(if (isDragging) 1.dp else 0.dp) + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt index 4d542cefb0..449b5cc6e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.signal.core.util.swap import org.thoughtcrime.securesms.database.model.StickerPackId import org.thoughtcrime.securesms.database.model.StickerPackKey import org.thoughtcrime.securesms.database.model.StickerPackRecord @@ -77,14 +78,16 @@ class StickerManagementViewModelV2 : ViewModel() { } } - fun installStickerPack(pack: AvailableStickerPack) = viewModelScope.launch { - updatePackDownloadStatus(pack.id, DownloadStatus.InProgress) + fun installStickerPack(pack: AvailableStickerPack) { + viewModelScope.launch { + updatePackDownloadStatus(pack.id, DownloadStatus.InProgress) - StickerManagementRepository.installStickerPack(packId = pack.id, packKey = pack.key, notify = true) - updatePackDownloadStatus(pack.id, DownloadStatus.Downloaded) + StickerManagementRepository.installStickerPack(packId = pack.id, packKey = pack.key, notify = true) + updatePackDownloadStatus(pack.id, DownloadStatus.Downloaded) - delay(1500) // wait, so we show the downloaded status for a bit before removing this row from the available sticker packs list - updatePackDownloadStatus(pack.id, null) + delay(1500) // wait, so we show the downloaded status for a bit before removing this row from the available sticker packs list + updatePackDownloadStatus(pack.id, null) + } } private fun updatePackDownloadStatus(packId: StickerPackId, newStatus: DownloadStatus?) { @@ -95,8 +98,20 @@ class StickerManagementViewModelV2 : ViewModel() { } } - fun uninstallStickerPack(pack: AvailableStickerPack) = viewModelScope.launch { - StickerManagementRepository.uninstallStickerPack(packId = pack.id, packKey = pack.key) + fun uninstallStickerPack(pack: AvailableStickerPack) { + viewModelScope.launch { + StickerManagementRepository.uninstallStickerPack(packId = pack.id, packKey = pack.key) + } + } + + fun updatePosition(fromIndex: Int, toIndex: Int) { + _uiState.update { it.copy(installedPacks = _uiState.value.installedPacks.swap(fromIndex, toIndex)) } + } + + fun saveInstalledPacksSortOrder() { + viewModelScope.launch { + StickerManagementRepository.setStickerPacksOrder(_uiState.value.installedPacks.map { it.record }) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt index 1e697dbbeb..b474ea3aff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt @@ -133,7 +133,7 @@ fun InstalledStickerPackRow( imageVector = ImageVector.vectorResource(id = R.drawable.ic_drag_handle), contentDescription = stringResource(R.string.StickerManagement_accessibility_drag_handle), tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier + modifier = Modifier .padding(horizontal = 12.dp) .size(24.dp) ) diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt b/core-ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt index 0a2d7da648..64e72ce06e 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.zIndex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent.OnItemMove /** * From AndroidX Compose demo @@ -36,12 +37,16 @@ import kotlinx.coroutines.launch * Supports adding non-draggable headers and footers. */ @Composable -fun rememberDragDropState(lazyListState: LazyListState, includeHeader: Boolean, includeFooter: Boolean, onMove: (Int, Int) -> Unit): DragDropState { +fun rememberDragDropState( + lazyListState: LazyListState, + includeHeader: Boolean, + includeFooter: Boolean, + onEvent: (DragAndDropEvent) -> Unit = {} +): DragDropState { val scope = rememberCoroutineScope() - val state = - remember(lazyListState) { - DragDropState(state = lazyListState, onMove = onMove, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope) - } + val state = remember(lazyListState) { + DragDropState(state = lazyListState, onEvent = onEvent, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope) + } LaunchedEffect(state) { while (true) { val diff = state.scrollChannel.receive() @@ -57,7 +62,7 @@ internal constructor( private val scope: CoroutineScope, private val includeHeader: Boolean, private val includeFooter: Boolean, - private val onMove: (Int, Int) -> Unit + private val onEvent: (DragAndDropEvent) -> Unit ) { var draggingItemIndex by mutableStateOf(null) private set @@ -94,7 +99,17 @@ internal constructor( } } - internal fun onDragInterrupted() { + internal fun onDragEnd() { + onDragInterrupted() + onEvent(DragAndDropEvent.OnItemDrop) + } + + internal fun onDragCancel() { + onDragInterrupted() + onEvent(DragAndDropEvent.OnDragCancel) + } + + private fun onDragInterrupted() { if (draggingItemIndex != null) { previousIndexOfDraggedItem = draggingItemIndex val startOffset = draggingItemOffset @@ -137,9 +152,9 @@ internal constructor( (!includeFooter || targetItem.index != (state.layoutInfo.totalItemsCount - 1)) ) { if (includeHeader) { - onMove.invoke(draggingItem.index - 1, targetItem.index - 1) + onEvent.invoke(OnItemMove(fromIndex = draggingItem.index - 1, toIndex = targetItem.index - 1)) } else { - onMove.invoke(draggingItem.index, targetItem.index) + onEvent.invoke(OnItemMove(fromIndex = draggingItem.index, toIndex = targetItem.index)) } draggingItemIndex = targetItem.index } else { @@ -161,7 +176,30 @@ internal constructor( get() = this.offset + this.size } -fun Modifier.dragContainer(dragDropState: DragDropState, leftDpOffset: Dp, rightDpOffset: Dp): Modifier { +sealed interface DragAndDropEvent { + /** + * Triggered when an item is moving from one position to another. + * + * The ordering of the corresponding UI state should be updated when this event is received. + */ + data class OnItemMove(val fromIndex: Int, val toIndex: Int) : DragAndDropEvent + + /** + * Triggered when a dragged item is dropped into its final position. + */ + data object OnItemDrop : DragAndDropEvent + + /** + * Triggered when a drag gesture is canceled. + */ + data object OnDragCancel : DragAndDropEvent +} + +fun Modifier.dragContainer( + dragDropState: DragDropState, + leftDpOffset: Dp, + rightDpOffset: Dp +): Modifier { return pointerInput(dragDropState) { detectDragGestures( onDrag = { change, offset -> @@ -169,8 +207,8 @@ fun Modifier.dragContainer(dragDropState: DragDropState, leftDpOffset: Dp, right dragDropState.onDrag(offset = offset) }, onDragStart = { offset -> dragDropState.onDragStart(offset) }, - onDragEnd = { dragDropState.onDragInterrupted() }, - onDragCancel = { dragDropState.onDragInterrupted() }, + onDragEnd = { dragDropState.onDragEnd() }, + onDragCancel = { dragDropState.onDragCancel() }, leftDpOffset = leftDpOffset, rightDpOffset = rightDpOffset ) diff --git a/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt b/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt index cd66da0c2c..5f06e46951 100644 --- a/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt @@ -1,8 +1,24 @@ package org.signal.core.util +import java.util.Collections + /** * Flattens a List of Map into a Map using the + operator. * * @return A Map containing all of the K, V pairings of the maps contained in the original list. */ fun List>.flatten(): Map = foldRight(emptyMap()) { a, b -> a + b } + +/** + * Swaps the elements at the specified positions and returns the result in a new immutable list. + * + * @param i the index of one element to be swapped. + * @param j the index of the other element to be swapped. + * + * @throws IndexOutOfBoundsException if either i or j is out of range. + */ +fun List.swap(i: Int, j: Int): List { + val mutableCopy = this.toMutableList() + Collections.swap(mutableCopy, i, j) + return mutableCopy.toList() +}