Sticker management v2 - Implement multi-select.

This commit is contained in:
Jeffrey Starke
2025-04-30 15:06:20 -04:00
committed by Cody Henthorne
parent 030678b029
commit c8bfc88bed
5 changed files with 291 additions and 92 deletions

View File

@@ -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<ActionItem> = 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<ActionItem>,
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)
}
)
}
}

View File

@@ -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<InstalledStickerPack>,
multiSelectEnabled: Boolean,
selectedPackIds: Set<StickerPackId>,
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"))
)
}
}

View File

@@ -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<AvailableStickerPack> = emptyList(),
val availableNotBlessedPacks: List<AvailableStickerPack> = emptyList(),
val installedPacks: List<InstalledStickerPack> = emptyList(),
val isMultiSelectMode: Boolean = false,
val multiSelectEnabled: Boolean = false,
val selectedPackIds: Set<StickerPackId> = 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)

View File

@@ -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",