diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index d70089316d..cee765b9c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java index c250415580..f0f399a29a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorStickerSelectActivity.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt index f093bb2007..24d448ec5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt @@ -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) = viewModel.onUninstallStickerPacksRequested(packIds) - override fun onRemoveStickerPacksConfirmed(packIds: Set) = 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) = viewModel.onUninstallStickerPacksRequested(packIds) + override fun onRemoveStickerPacksConfirmed(packIds: Set) = 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementBottomSheet.kt new file mode 100644 index 0000000000..adc498004f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementBottomSheet.kt @@ -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) = viewModel.onUninstallStickerPacksRequested(packIds) + override fun onRemoveStickerPacksConfirmed(packIds: Set) = 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)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt index 3d53a44b0a..3e0ee7ff79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivityV2.kt @@ -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) + } } ) } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt index 8410671538..6b043694a9 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt @@ -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