Open sticker management in a bottom sheet on large screen devices.

This commit is contained in:
jeffrey-signal
2026-01-07 14:49:16 -05:00
parent b56e2222f5
commit c2ec9e579e
6 changed files with 260 additions and 41 deletions

View File

@@ -321,7 +321,7 @@ import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.stickers.StickerEventListener
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.stickers.StickerManagementActivity
import org.thoughtcrime.securesms.stickers.StickerManagementScreen
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent
import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity
import org.thoughtcrime.securesms.stories.StoryViewerArgs
@@ -895,7 +895,7 @@ class ConversationFragment :
}
override fun onStickerManagementClicked() {
startActivity(StickerManagementActivity.createIntent(requireContext()))
StickerManagementScreen.show(this)
container.hideInput()
}

View File

@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.keyboard.sticker.StickerSearchDialogFragment;
import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker;
import org.thoughtcrime.securesms.scribbles.stickers.ScribbleStickersFragment;
import org.thoughtcrime.securesms.stickers.StickerEventListener;
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
import org.thoughtcrime.securesms.stickers.StickerManagementScreen;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class ImageEditorStickerSelectActivity extends AppCompatActivity implements StickerEventListener, MediaKeyboard.MediaKeyboardListener, StickerKeyboardPageFragment.Callback, ScribbleStickersFragment.Callback {
@@ -66,10 +66,9 @@ public final class ImageEditorStickerSelectActivity extends AppCompatActivity im
@Override
public void onStickerManagementClicked() {
startActivity(StickerManagementActivity.createIntent(ImageEditorStickerSelectActivity.this));
StickerManagementScreen.show(this);
}
@Override
public void openStickerSearch() {
StickerSearchDialogFragment.show(getSupportFragmentManager());

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.stickers
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -15,6 +16,7 @@ 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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -58,10 +60,13 @@ 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.dp
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -86,11 +91,13 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StickerPackKey
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus
import org.thoughtcrime.securesms.stickers.StickerManagementActivity.Companion.createIntent
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.getWindowSizeClass
import java.text.NumberFormat
/**
* Displays all of the available and installed sticker packs, enabling installation, uninstallation, and sorting.
* Displays all the available and installed sticker packs, enabling installation, uninstallation, and sorting.
*/
class StickerManagementActivity : PassphraseRequiredActivity() {
companion object {
@@ -117,28 +124,32 @@ class StickerManagementActivity : PassphraseRequiredActivity() {
uiState = uiState,
onNavigateBack = ::supportFinishAfterTransition,
onSetMultiSelectModeEnabled = viewModel::setMultiSelectEnabled,
onSnackbarDismiss = { viewModel.onSnackbarDismiss() },
availableTabCallbacks = object : AvailableStickersContentCallbacks {
override fun onForwardClick(pack: AvailableStickerPack) = openShareSheet(pack.id, pack.key)
override fun onInstallClick(pack: AvailableStickerPack) = viewModel.installStickerPack(pack)
override fun onShowPreviewClick(pack: AvailableStickerPack) = navigateToStickerPreview(pack.id, pack.key)
},
installedTabCallbacks = object : InstalledStickersContentCallbacks {
override fun onForwardClick(pack: InstalledStickerPack) = openShareSheet(pack.id, pack.key)
override fun onRemoveClick(packIds: Set<StickerPackId>) = viewModel.onUninstallStickerPacksRequested(packIds)
override fun onRemoveStickerPacksConfirmed(packIds: Set<StickerPackId>) = viewModel.onUninstallStickerPacksConfirmed(packIds)
override fun onRemoveStickerPacksCanceled() = viewModel.onUninstallStickerPacksCanceled()
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)
is DragAndDropEvent.OnItemDrop -> viewModel.saveInstalledPacksSortOrder()
is DragAndDropEvent.OnDragCancel -> {}
}
onSnackbarDismiss = viewModel::onSnackbarDismiss,
availableTabCallbacks = remember {
object : AvailableStickersContentCallbacks {
override fun onForwardClick(pack: AvailableStickerPack) = openShareSheet(pack.id, pack.key)
override fun onInstallClick(pack: AvailableStickerPack) = viewModel.installStickerPack(pack)
override fun onShowPreviewClick(pack: AvailableStickerPack) = navigateToStickerPreview(pack.id, pack.key)
}
},
installedTabCallbacks = remember {
object : InstalledStickersContentCallbacks {
override fun onForwardClick(pack: InstalledStickerPack) = openShareSheet(pack.id, pack.key)
override fun onRemoveClick(packIds: Set<StickerPackId>) = viewModel.onUninstallStickerPacksRequested(packIds)
override fun onRemoveStickerPacksConfirmed(packIds: Set<StickerPackId>) = viewModel.onUninstallStickerPacksConfirmed(packIds)
override fun onRemoveStickerPacksCanceled() = viewModel.onUninstallStickerPacksCanceled()
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)
is DragAndDropEvent.OnItemDrop -> viewModel.saveInstalledPacksSortOrder()
is DragAndDropEvent.OnDragCancel -> {}
}
}
override fun onShowPreviewClick(pack: InstalledStickerPack) = navigateToStickerPreview(pack.id, pack.key)
override fun onShowPreviewClick(pack: InstalledStickerPack) = navigateToStickerPreview(pack.id, pack.key)
}
}
)
}
@@ -203,10 +214,43 @@ interface InstalledStickersContentCallbacks {
}
}
object StickerManagementScreen {
/**
* Shows the screen as a bottom sheet on large devices (tablets/foldables), activity on phones.
*/
@JvmStatic
fun show(activity: FragmentActivity) {
if (showAsBottomSheet(activity.resources)) {
StickerManagementBottomSheet.show(activity.supportFragmentManager)
} else {
activity.startActivity(createIntent(activity))
}
}
/**
* Shows the screen as a bottom sheet on large devices (tablets/foldables), activity on phones.
*/
fun show(fragment: Fragment) {
if (showAsBottomSheet(fragment.resources)) {
StickerManagementBottomSheet.show(fragment.parentFragmentManager)
} else {
fragment.startActivity(createIntent(fragment.requireContext()))
}
}
private fun showAsBottomSheet(resources: Resources): Boolean {
return resources.getWindowSizeClass().isAtLeastBreakpoint(
widthDpBreakpoint = WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND,
heightDpBreakpoint = WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StickerManagementScreen(
fun StickerManagementScreen(
uiState: StickerManagementUiState,
showNavigateBack: Boolean = true,
onNavigateBack: () -> Unit = {},
onSetMultiSelectModeEnabled: (Boolean) -> Unit = {},
onSnackbarDismiss: () -> Unit = {},
@@ -252,6 +296,7 @@ private fun StickerManagementScreen(
)
} else {
TopAppBar(
showNavigateBack = showNavigateBack,
onBackPress = onNavigateBack,
showMenuButton = isInstalledTabActive,
onSetMultiSelectModeEnabled = onSetMultiSelectModeEnabled
@@ -300,6 +345,7 @@ private fun StickerManagementScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(
showNavigateBack: Boolean = true,
showMenuButton: Boolean = false,
onBackPress: () -> Unit,
onSetMultiSelectModeEnabled: (Boolean) -> Unit
@@ -307,9 +353,21 @@ private fun TopAppBar(
Scaffolds.DefaultTopAppBar(
title = stringResource(R.string.StickerManagement_title_stickers),
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description),
onNavigationClick = onBackPress,
navigationIconContent = {
if (showNavigateBack) {
IconButton(
onClick = onBackPress,
modifier = Modifier.padding(end = 16.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
contentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description)
)
}
} else {
Spacer(modifier = Modifier.padding(end = 16.dp))
}
},
actions = {
if (showMenuButton) {
val menuController = remember { DropdownMenus.MenuController() }
@@ -605,7 +663,9 @@ private fun SnackbarHost(
val snackbarMessage = when (actionConfirmation) {
is StickerManagementConfirmation.InstalledPack -> stringResource(R.string.StickerManagement_installed_pack_s, actionConfirmation.packTitle)
is StickerManagementConfirmation.UninstalledPack -> stringResource(R.string.StickerManagement_deleted_pack_s, actionConfirmation.packTitle)
is StickerManagementConfirmation.UninstalledPacks -> pluralStringResource(
R.plurals.StickerManagement_deleted_n_packs,
actionConfirmation.numPacksUninstalled,

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stickers
import android.app.Dialog
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
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.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StickerPackKey
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.viewModel
/**
* Bottom sheet implementation of [StickerManagementScreen].
*/
class StickerManagementBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val TAG = "StickerManagementSheet"
@JvmStatic
fun show(fragmentManager: FragmentManager) {
StickerManagementBottomSheet().show(fragmentManager, TAG)
}
}
private val viewModel by viewModel { StickerManagementViewModel() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onScreenVisible()
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
return dialog.apply {
behavior.skipCollapsed = true
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
@Composable
override fun SheetContent() {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
BottomSheets.Handle()
}
StickerManagementScreen(
uiState = uiState,
showNavigateBack = false,
onNavigateBack = ::dismiss,
onSetMultiSelectModeEnabled = viewModel::setMultiSelectEnabled,
onSnackbarDismiss = viewModel::onSnackbarDismiss,
availableTabCallbacks = remember {
object : AvailableStickersContentCallbacks {
override fun onForwardClick(pack: AvailableStickerPack) = openShareSheet(pack.id, pack.key)
override fun onInstallClick(pack: AvailableStickerPack) = viewModel.installStickerPack(pack)
override fun onShowPreviewClick(pack: AvailableStickerPack) = navigateToStickerPreview(pack.id, pack.key)
}
},
installedTabCallbacks = remember {
object : InstalledStickersContentCallbacks {
override fun onForwardClick(pack: InstalledStickerPack) = openShareSheet(pack.id, pack.key)
override fun onRemoveClick(packIds: Set<StickerPackId>) = viewModel.onUninstallStickerPacksRequested(packIds)
override fun onRemoveStickerPacksConfirmed(packIds: Set<StickerPackId>) = viewModel.onUninstallStickerPacksConfirmed(packIds)
override fun onRemoveStickerPacksCanceled() = viewModel.onUninstallStickerPacksCanceled()
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)
is DragAndDropEvent.OnItemDrop -> viewModel.saveInstalledPacksSortOrder()
is DragAndDropEvent.OnDragCancel -> {}
}
}
override fun onShowPreviewClick(pack: InstalledStickerPack) = navigateToStickerPreview(pack.id, pack.key)
}
}
)
}
}
private fun openShareSheet(packId: StickerPackId, packKey: StickerPackKey) {
MultiselectForwardFragment.showBottomSheet(
supportFragmentManager = parentFragmentManager,
multiselectForwardFragmentArgs = MultiselectForwardFragmentArgs(
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(StickerUrl.createShareLink(packId.value, packKey.value))
.build()
),
title = R.string.StickerManagement_share_sheet_title
)
)
}
private fun navigateToStickerPreview(packId: StickerPackId, packKey: StickerPackKey) {
startActivity(StickerPackPreviewActivity.getIntent(packId.value, packKey.value))
}
}

View File

@@ -32,6 +32,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.vector.ImageVector
@@ -106,11 +107,13 @@ class StickerPackPreviewActivityV2 : PassphraseRequiredActivity() {
SignalTheme {
StickerPackPreviewScreen(
uiState = uiState,
callbacks = object : StickerPackPreviewScreenCallbacks {
override fun onNavigateTo(target: NavTarget) = navigateToTarget(target)
override fun onForwardClick(params: StickerPackParams) = openShareSheet(params)
override fun onInstallClick(params: StickerPackParams) = viewModel.installStickerPack(params)
override fun onUninstallClick(params: StickerPackParams) = viewModel.uninstallStickerPack(params)
callbacks = remember {
object : StickerPackPreviewScreenCallbacks {
override fun onNavigateTo(target: NavTarget) = navigateToTarget(target)
override fun onForwardClick(params: StickerPackParams) = openShareSheet(params)
override fun onInstallClick(params: StickerPackParams) = viewModel.installStickerPack(params)
override fun onUninstallClick(params: StickerPackParams) = viewModel.uninstallStickerPack(params)
}
}
)
}

View File

@@ -72,7 +72,7 @@ object Scaffolds {
}
/**
* Top app bar that takes an ImageVector
* Top app bar that takes an ImageVector navigation icon.
*/
@Composable
fun DefaultTopAppBar(
@@ -84,11 +84,10 @@ object Scaffolds {
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
) {
TopAppBar(
title = {
titleContent(scrollBehavior.state.contentOffset, title)
},
navigationIcon = {
DefaultTopAppBar(
title = title,
titleContent = titleContent,
navigationIconContent = {
if (navigationIcon != null) {
IconButton(
onClick = onNavigationClick,
@@ -101,6 +100,25 @@ object Scaffolds {
}
}
},
actions = actions,
scrollBehavior = scrollBehavior
)
}
/**
* Top app bar that takes composable navigation icon.
*/
@Composable
fun DefaultTopAppBar(
title: String,
titleContent: @Composable (Float, String) -> Unit,
navigationIconContent: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
) {
TopAppBar(
title = { titleContent(scrollBehavior.state.contentOffset, title) },
navigationIcon = navigationIconContent,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = SignalTheme.colors.colorSurface2