diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt index 157d08b58b..d8ffa7b945 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/SignalBottomActionBar.kt @@ -10,6 +10,21 @@ import android.view.animation.AnimationUtils import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.interpolator.view.animation.FastOutSlowInInterpolator import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.util.ViewUtil @@ -20,7 +35,7 @@ import org.thoughtcrime.securesms.util.ViewUtil * * Overflow items are rendered in a [SignalContextMenu]. */ -class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : LinearLayout(context, attributeSet) { +class SignalBottomActionBar(context: Context, attributeSet: AttributeSet?) : LinearLayout(context, attributeSet) { val items: MutableList = mutableListOf() @@ -118,3 +133,46 @@ class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : Line view.setOnClickListener { item.action.run() } } } + +@Composable +fun SignalBottomActionBar( + visible: Boolean = true, + items: List, + modifier: Modifier = Modifier +) { + val slideAnimationOffset = with(LocalDensity.current) { 40.dp.roundToPx() } + + val enterAnimation = slideInVertically( + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + initialOffsetY = { slideAnimationOffset } + ) + fadeIn( + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) + + val exitAnimation = slideOutVertically( + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + targetOffsetY = { slideAnimationOffset } + ) + fadeOut( + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) + + AnimatedVisibility( + visible = visible, + enter = enterAnimation, + exit = exitAnimation, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + .wrapContentHeight() + ) { + AndroidView( + factory = { context -> + SignalBottomActionBar(context, null) + .apply { setItems(items) } + }, + update = { view -> + view.setItems(items) + } + ) + } +} 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 d4aa96702b..bb425c117d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt @@ -11,6 +11,8 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight @@ -32,19 +34,25 @@ import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource 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.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -64,6 +72,8 @@ 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 +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.model.StickerPackId @@ -71,6 +81,7 @@ import org.thoughtcrime.securesms.database.model.StickerPackKey import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus import org.thoughtcrime.securesms.util.viewModel +import java.text.NumberFormat /** * Displays all of the available and installed sticker packs, enabling installation, uninstallation, and sorting. @@ -99,14 +110,16 @@ class StickerManagementActivityV2 : PassphraseRequiredActivity() { StickerManagementScreen( uiState = uiState, onNavigateBack = ::supportFinishAfterTransition, + onExitMultiSelectMode = { viewModel.setMultiSelectEnabled(false) }, availableTabCallbacks = object : AvailableStickersContentCallbacks { override fun onForwardClick(pack: AvailableStickerPack) = openShareSheet(pack.id, pack.key) override fun onInstallClick(pack: AvailableStickerPack) = viewModel.installStickerPack(pack) }, installedTabCallbacks = object : InstalledStickersContentCallbacks { override fun onForwardClick(pack: InstalledStickerPack) = openShareSheet(pack.id, pack.key) - override fun onSelectClick(pack: InstalledStickerPack) = viewModel.toggleSelection(pack) override fun onRemoveClick(pack: InstalledStickerPack) = viewModel.uninstallStickerPack(pack) + override fun onSelectionToggle(pack: InstalledStickerPack) = viewModel.toggleSelection(pack) + override fun onSelectAllToggle() = viewModel.toggleSelectAll() override fun onDragAndDropEvent(event: DragAndDropEvent) { when (event) { is DragAndDropEvent.OnItemMove -> viewModel.updatePosition(event.fromIndex, event.toIndex) @@ -152,14 +165,16 @@ interface AvailableStickersContentCallbacks { interface InstalledStickersContentCallbacks { fun onForwardClick(pack: InstalledStickerPack) - fun onSelectClick(pack: InstalledStickerPack) fun onRemoveClick(pack: InstalledStickerPack) + fun onSelectionToggle(pack: InstalledStickerPack) + fun onSelectAllToggle() fun onDragAndDropEvent(event: DragAndDropEvent) object Empty : InstalledStickersContentCallbacks { override fun onForwardClick(pack: InstalledStickerPack) = Unit - override fun onSelectClick(pack: InstalledStickerPack) = Unit override fun onRemoveClick(pack: InstalledStickerPack) = Unit + override fun onSelectionToggle(pack: InstalledStickerPack) = Unit + override fun onSelectAllToggle() = Unit override fun onDragAndDropEvent(event: DragAndDropEvent) = Unit } } @@ -169,39 +184,50 @@ interface InstalledStickersContentCallbacks { private fun StickerManagementScreen( uiState: StickerManagementUiState, onNavigateBack: () -> Unit = {}, + onExitMultiSelectMode: () -> Unit = {}, availableTabCallbacks: AvailableStickersContentCallbacks = AvailableStickersContentCallbacks.Empty, installedTabCallbacks: InstalledStickersContentCallbacks = InstalledStickersContentCallbacks.Empty, modifier: Modifier = Modifier ) { - Scaffold( - topBar = { TopAppBar(onBackPress = onNavigateBack) } - ) { padding -> - - val pages = listOf( - Page( - title = stringResource(R.string.StickerManagement_available_tab_label), - getContent = { - AvailableStickersContent( - blessedPacks = uiState.availableBlessedPacks, - notBlessedPacks = uiState.availableNotBlessedPacks, - callbacks = availableTabCallbacks - ) - } - ), - Page( - title = stringResource(R.string.StickerManagement_installed_tab_label), - getContent = { - InstalledStickersContent( - packs = uiState.installedPacks, - callbacks = installedTabCallbacks - ) - } - ) + val pages = listOf( + Page( + title = stringResource(R.string.StickerManagement_available_tab_label), + getContent = { + AvailableStickersContent( + blessedPacks = uiState.availableBlessedPacks, + notBlessedPacks = uiState.availableNotBlessedPacks, + callbacks = availableTabCallbacks + ) + } + ), + Page( + title = stringResource(R.string.StickerManagement_installed_tab_label), + getContent = { + InstalledStickersContent( + packs = uiState.installedPacks, + multiSelectEnabled = uiState.multiSelectEnabled, + selectedPackIds = uiState.selectedPackIds, + callbacks = installedTabCallbacks + ) + } ) + ) - val pagerState = rememberPagerState(pageCount = { pages.size }) - val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { pages.size }) + val coroutineScope = rememberCoroutineScope() + Scaffold( + topBar = { + if (pagerState.currentPage == 1 && uiState.multiSelectEnabled) { + MultiSelectTopAppBar( + selectedItemCount = uiState.selectedPackIds.size, + onExitClick = onExitMultiSelectMode + ) + } else { + TopAppBar(onBackPress = onNavigateBack) + } + } + ) { padding -> Column( modifier = modifier.padding(padding) ) { @@ -248,6 +274,21 @@ private fun TopAppBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MultiSelectTopAppBar( + selectedItemCount: Int, + onExitClick: () -> Unit = {} +) { + Scaffolds.DefaultTopAppBar( + title = pluralStringResource(R.plurals.StickerManagement_title_n_selected, selectedItemCount, NumberFormat.getInstance().format(selectedItemCount)), + titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) }, + navigationIconPainter = painterResource(R.drawable.symbol_x_24), + navigationContentDescription = stringResource(R.string.StickerManagement_accessibility_exit_multi_select_mode), + onNavigationClick = onExitClick + ) +} + @Composable private fun PagerTab( title: String, @@ -285,6 +326,7 @@ private fun AvailableStickersContent( LazyColumn( contentPadding = PaddingValues(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.fillMaxHeight() ) { if (blessedPacks.isNotEmpty()) { @@ -349,6 +391,8 @@ private fun AvailableStickersContent( @Composable private fun InstalledStickersContent( packs: List, + multiSelectEnabled: Boolean, + selectedPackIds: Set, callbacks: InstalledStickersContentCallbacks = InstalledStickersContentCallbacks.Empty, modifier: Modifier = Modifier ) { @@ -360,54 +404,96 @@ private fun InstalledStickersContent( val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val density = LocalDensity.current val haptics = LocalHapticFeedback.current - LazyColumn( - contentPadding = PaddingValues(top = 8.dp), - 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 { - DraggableItem(dragDropState, 0) { - StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_installed_stickers_header)) - } - } + Box(modifier = Modifier.fillMaxSize()) { + var bottomActionBarPadding: Dp by remember { mutableStateOf(0.dp) } - itemsIndexed( - items = packs, - key = { _, pack -> pack.id.value } - ) { index, pack -> - val menuController = remember { DropdownMenus.MenuController() } - - DraggableItem( - index = index + 1, - dragDropState = dragDropState - ) { isDragging -> - InstalledStickerPackRow( - pack = pack, - menuController = menuController, - onForwardClick = { callbacks.onForwardClick(pack) }, - onSelectClick = { callbacks.onSelectClick(pack) }, - onRemoveClick = { callbacks.onRemoveClick(pack) }, - modifier = Modifier - .shadow(if (isDragging) 1.dp else 0.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - menuController.show() - }, - onLongClickLabel = stringResource(R.string.StickerManagement_accessibility_open_context_menu) - ) + LazyColumn( + contentPadding = PaddingValues( + top = 8.dp, + bottom = if (multiSelectEnabled) bottomActionBarPadding else 0.dp + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + 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 { + DraggableItem(dragDropState, 0) { + StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_installed_stickers_header)) + } + } + + itemsIndexed( + items = packs, + key = { _, pack -> pack.id.value } + ) { index, pack -> + val menuController = remember { DropdownMenus.MenuController() } + + DraggableItem( + index = index + 1, + dragDropState = dragDropState + ) { isDragging -> + InstalledStickerPackRow( + pack = pack, + multiSelectEnabled = multiSelectEnabled, + selected = pack.id in selectedPackIds, + menuController = menuController, + onForwardClick = { callbacks.onForwardClick(pack) }, + onSelectionToggle = { callbacks.onSelectionToggle(pack) }, + onRemoveClick = { callbacks.onRemoveClick(pack) }, + modifier = Modifier + .shadow(if (isDragging) 1.dp else 0.dp) + .combinedClickable( + onClick = { + if (multiSelectEnabled) { + callbacks.onSelectionToggle(pack) + } + }, + onLongClick = { + if (!multiSelectEnabled) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + menuController.show() + } + }, + onLongClickLabel = stringResource(R.string.StickerManagement_accessibility_open_context_menu) + ) + ) + } } } + + SignalBottomActionBar( + visible = multiSelectEnabled, + items = listOf( + ActionItem( + iconRes = R.drawable.symbol_check_circle_24, + title = if (selectedPackIds.size == packs.size) { + stringResource(R.string.StickerManagement_action_deselect_all) + } else { + stringResource(R.string.StickerManagement_action_select_all) + }, + action = callbacks::onSelectAllToggle + ), + ActionItem( + iconRes = R.drawable.symbol_trash_24, + title = stringResource(R.string.StickerManagement_action_delete_selected), + action = { /* TODO implement multi-delete */ } + ) + ), + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { layoutCoordinates -> + bottomActionBarPadding = with(density) { layoutCoordinates.size.height.toDp() } + } + ) } } } @@ -478,6 +564,7 @@ private fun InstalledStickersContentPreview() { isBlessed = true ), StickerPreviewDataFactory.installedPack( + packId = "stickerPackId2", title = "Bandit the Cat", author = "Agnes Lee", isBlessed = true @@ -486,7 +573,9 @@ private fun InstalledStickersContentPreview() { title = "Day by Day", author = "Miguel Ángel Camprubí" ) - ) + ), + multiSelectEnabled = true, + selectedPackIds = setOf(StickerPackId("stickerPackId2")) ) } } 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 1f75102be9..c14095d6ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt @@ -116,9 +116,32 @@ class StickerManagementViewModelV2 : ViewModel() { fun toggleSelection(pack: InstalledStickerPack) { _uiState.update { previousState -> + val wasItemSelected = previousState.selectedPackIds.contains(pack.id) previousState.copy( - isMultiSelectMode = true, - selectedPackIds = if (pack.isSelected) previousState.selectedPackIds.minus(pack.id) else previousState.selectedPackIds.plus(pack.id) + multiSelectEnabled = true, + selectedPackIds = if (wasItemSelected) previousState.selectedPackIds.minus(pack.id) else previousState.selectedPackIds.plus(pack.id) + ) + } + } + + fun toggleSelectAll() { + _uiState.update { previousState -> + previousState.copy( + multiSelectEnabled = true, + selectedPackIds = if (previousState.selectedPackIds.size == previousState.installedPacks.size) { + emptySet() + } else { + previousState.installedPacks.map { it.id }.toSet() + } + ) + } + } + + fun setMultiSelectEnabled(isEnabled: Boolean) { + _uiState.update { previousState -> + previousState.copy( + multiSelectEnabled = isEnabled, + selectedPackIds = emptySet() ) } } @@ -128,7 +151,7 @@ data class StickerManagementUiState( val availableBlessedPacks: List = emptyList(), val availableNotBlessedPacks: List = emptyList(), val installedPacks: List = emptyList(), - val isMultiSelectMode: Boolean = false, + val multiSelectEnabled: Boolean = false, val selectedPackIds: Set = emptySet() ) @@ -150,8 +173,7 @@ data class AvailableStickerPack( data class InstalledStickerPack( val record: StickerPackRecord, val isBlessed: Boolean, - val sortOrder: Int, - val isSelected: Boolean = false + val sortOrder: Int ) { val id = StickerPackId(record.packId) val key = StickerPackKey(record.packKey) 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 6232b1ef7c..e9ed7c81ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt @@ -5,6 +5,11 @@ package org.thoughtcrime.securesms.stickers +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -13,6 +18,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -64,8 +70,12 @@ fun AvailableStickerPackRow( Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier - .background(MaterialTheme.colorScheme.surface) - .padding(start = 24.dp, top = 12.dp, end = 12.dp, bottom = 12.dp) + .padding(horizontal = 16.dp) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(18.dp) + ) + .padding(vertical = 10.dp) ) { StickerPackInfo( coverImageUri = DecryptableUri(pack.record.cover.uri), @@ -132,25 +142,32 @@ fun AvailableStickerPackRow( @Composable fun InstalledStickerPackRow( pack: InstalledStickerPack, - multiSelectModeEnabled: Boolean = false, - checked: Boolean = false, - onCheckedChange: (Boolean) -> Unit = {}, + multiSelectEnabled: Boolean = false, + selected: Boolean = false, menuController: DropdownMenus.MenuController, onForwardClick: (InstalledStickerPack) -> Unit = {}, onRemoveClick: (InstalledStickerPack) -> Unit = {}, - onSelectClick: (InstalledStickerPack) -> Unit = {}, + onSelectionToggle: (InstalledStickerPack) -> Unit = {}, modifier: Modifier = Modifier ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier - .background(MaterialTheme.colorScheme.surface) - .padding(12.dp) + .padding(horizontal = 16.dp) + .background( + color = if (selected) SignalTheme.colors.colorSurface2 else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(18.dp) + ) + .padding(vertical = 10.dp) ) { - if (multiSelectModeEnabled) { + AnimatedVisibility( + visible = multiSelectEnabled, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally() + ) { Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, + checked = selected, + onCheckedChange = { onSelectionToggle(pack) }, modifier = Modifier.padding(end = 8.dp) ) } @@ -191,7 +208,7 @@ fun InstalledStickerPackRow( icon = ImageVector.vectorResource(R.drawable.symbol_check_circle_24), text = stringResource(R.string.StickerManagement_menu_select_pack), onClick = { - onSelectClick(pack) + onSelectionToggle(pack) menuController.hide() } ) @@ -324,7 +341,7 @@ private fun AvailableStickerPackRowPreviewDownloaded() = SignalTheme { @Composable private fun InstalledStickerPackRowPreview() = SignalTheme { InstalledStickerPackRow( - multiSelectModeEnabled = false, + multiSelectEnabled = false, menuController = DropdownMenus.MenuController(), pack = StickerPreviewDataFactory.installedPack( title = "Bandit the Cat", @@ -338,7 +355,7 @@ private fun InstalledStickerPackRowPreview() = SignalTheme { @Composable private fun InstalledStickerPackRowSelectModePreview() = SignalTheme { InstalledStickerPackRow( - multiSelectModeEnabled = true, + multiSelectEnabled = true, menuController = DropdownMenus.MenuController(), pack = StickerPreviewDataFactory.installedPack( title = "Bandit the Cat", diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3673ce72f7..c8e10de923 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2801,6 +2801,11 @@ Stickers + + + %1$s selected + %1$s selected + Available @@ -2819,6 +2824,12 @@ Signal artist series Forward to + + Select all + + Deselect all + + Delete Forward @@ -2839,6 +2850,8 @@ Download Open context menu + + Exit selection mode and clear selections No stickers installed