diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java index abc9564db8..3e4968c0f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java @@ -123,7 +123,7 @@ public final class StickerManagementActivity extends PassphraseRequiredActivity } private void initViewModel() { - StickerManagementRepository repository = new StickerManagementRepository(this); + StickerManagementRepository repository = new StickerManagementRepository(); viewModel = new ViewModelProvider(this, new StickerManagementViewModel.Factory(getApplication(), repository)).get(StickerManagementViewModel.class); viewModel.init(); 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 27a005d27b..7674cecfb7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt @@ -10,9 +10,13 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize 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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,16 +35,22 @@ 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.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +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.theme.SignalTheme import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus import org.thoughtcrime.securesms.util.viewModel +/** + * Displays all of the available and installed sticker packs, enabling installation, uninstallation, and sorting. + */ class StickerManagementActivityV2 : PassphraseRequiredActivity() { companion object { @JvmStatic @@ -84,7 +94,7 @@ private fun StickerManagementScreen( val pages = listOf( Page( title = stringResource(R.string.StickerManagement_available_tab_label), - getContent = { AvailableStickersContent(uiState.availablePacks) } + getContent = { AvailableStickersContent(blessedPacks = uiState.availableBlessedPacks, availablePacks = uiState.availablePacks) } ), Page( title = stringResource(R.string.StickerManagement_installed_tab_label), @@ -164,23 +174,64 @@ private fun PagerTab( @Composable private fun AvailableStickersContent( - packs: List + blessedPacks: List, + availablePacks: List, + modifier: Modifier = Modifier ) { - if (packs.isEmpty()) { + if (blessedPacks.isEmpty() && availablePacks.isEmpty()) { EmptyView(text = stringResource(R.string.StickerManagement_available_tab_empty_text)) } else { - // TODO show available stickers list + LazyColumn( + contentPadding = PaddingValues(top = 8.dp), + modifier = modifier.fillMaxHeight() + ) { + if (blessedPacks.isNotEmpty()) { + item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_signal_artist_series_header)) } + items( + items = blessedPacks, + key = { it.record.packId } + ) { + AvailableStickerPackRow(it) + } + } + + if (blessedPacks.isNotEmpty() && availablePacks.isNotEmpty()) { + item { Dividers.Default() } + } + + if (availablePacks.isNotEmpty()) { + item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_stickers_you_received_header)) } + items( + items = availablePacks, + key = { it.record.packId } + ) { + AvailableStickerPackRow(it) + } + } + } } } @Composable private fun InstalledStickersContent( - packs: List + packs: List, + modifier: Modifier = Modifier ) { if (packs.isEmpty()) { EmptyView(text = stringResource(R.string.StickerManagement_installed_tab_empty_text)) } else { - // TODO show installed stickers list + LazyColumn( + contentPadding = PaddingValues(top = 8.dp), + modifier = modifier.fillMaxHeight() + ) { + item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_installed_stickers_header)) } + items( + items = packs, + key = { it.record.packId } + ) { + InstalledStickerPackRow(it) + } + } } } @@ -210,3 +261,58 @@ private fun StickerManagementScreenEmptyStatePreview() { ) } } + +@SignalPreview +@Composable +private fun AvailableStickersContentPreview() { + Previews.Preview { + AvailableStickersContent( + blessedPacks = listOf( + StickerPreviewDataFactory.availablePack( + title = "Swoon / Faces", + author = "Swoon", + isBlessed = true + ) + ), + availablePacks = listOf( + StickerPreviewDataFactory.availablePack( + title = "Bandit the Cat", + author = "Agnes Lee", + isBlessed = false, + downloadStatus = DownloadStatus.InProgress(progressPercent = 22.0) + ), + StickerPreviewDataFactory.availablePack( + title = "Day by Day", + author = "Miguel Ángel Camprubí", + isBlessed = false, + downloadStatus = DownloadStatus.Downloaded + ) + ) + ) + } +} + +@SignalPreview +@Composable +private fun InstalledStickersContentPreview() { + Previews.Preview { + InstalledStickersContent( + packs = listOf( + StickerPreviewDataFactory.installedPack( + title = "Swoon / Faces", + author = "Swoon", + isBlessed = true + ), + StickerPreviewDataFactory.installedPack( + title = "Bandit the Cat", + author = "Agnes Lee", + isBlessed = true + ), + StickerPreviewDataFactory.installedPack( + title = "Day by Day", + author = "Miguel Ángel Camprubí" + ) + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java index dc82a103d2..9be3242b0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java @@ -46,12 +46,12 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter sections = new ArrayList(3) {{ StickerSection yourStickers = new StickerSection(TAG_YOUR_STICKERS, - R.string.StickerManagementAdapter_installed_stickers, + R.string.StickerManagement_installed_stickers_header, R.string.StickerManagementAdapter_no_stickers_installed, new ArrayList<>(), 0); StickerSection messageStickers = new StickerSection(TAG_MESSAGE_STICKERS, - R.string.StickerManagementAdapter_stickers_you_received, + R.string.StickerManagement_stickers_you_received_header, R.string.StickerManagementAdapter_stickers_from_incoming_messages_will_appear_here, new ArrayList<>(), yourStickers.size()); @@ -127,17 +127,17 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter blessedPacks) { StickerSection yourStickers = new StickerSection(TAG_YOUR_STICKERS, - R.string.StickerManagementAdapter_installed_stickers, + R.string.StickerManagement_installed_stickers_header, R.string.StickerManagementAdapter_no_stickers_installed, installedPacks, 0); StickerSection blessedStickers = new StickerSection(TAG_BLESSED_STICKERS, - R.string.StickerManagementAdapter_signal_artist_series, + R.string.StickerManagement_signal_artist_series_header, 0, blessedPacks, yourStickers.size()); StickerSection messageStickers = new StickerSection(TAG_MESSAGE_STICKERS, - R.string.StickerManagementAdapter_stickers_you_received, + R.string.StickerManagement_stickers_you_received_header, R.string.StickerManagementAdapter_stickers_from_incoming_messages_will_appear_here, availablePacks, yourStickers.size() + (blessedPacks.isEmpty() ? 0 : blessedStickers.size())); @@ -270,7 +270,7 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter = _uiState.asStateFlow() + + init { + stickerManagementRepo.deleteOrphanedStickerPacks() + stickerManagementRepo.fetchUnretrievedReferencePacks() + loadStickerPacks() + } + + private fun loadStickerPacks() { + stickerManagementRepo.getStickerPacks { result -> + _uiState.value = _uiState.value.copy( + availableBlessedPacks = result.blessedPacks + .map { AvailableStickerPack(record = it, isBlessed = true, downloadStatus = DownloadStatus.NotDownloaded) }, + availablePacks = result.availablePacks + .map { AvailableStickerPack(record = it, isBlessed = false, downloadStatus = DownloadStatus.NotDownloaded) }, + installedPacks = result.installedPacks + .mapIndexed { index, record -> InstalledStickerPack(record = record, isBlessed = BlessedPacks.contains(record.packId), sortOrder = index) } + ) + } + } } data class StickerManagementUiState( + val availableBlessedPacks: List = emptyList(), val availablePacks: List = emptyList(), val installedPacks: List = emptyList(), val isMultiSelectMode: Boolean = false @@ -35,7 +58,8 @@ data class AvailableStickerPack( } data class InstalledStickerPack( - private val record: StickerPackRecord, + val record: StickerPackRecord, + val isBlessed: Boolean, val sortOrder: Int, - val isSelected: Boolean + val isSelected: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt new file mode 100644 index 0000000000..c3fb7fb68c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stickers + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.IconButtons +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.util.nullIfBlank +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.GlideImage +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus + +@Composable +fun StickerPackSectionHeader( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 24.dp, vertical = 12.dp) + ) +} + +@Composable +fun AvailableStickerPackRow( + pack: AvailableStickerPack, + onStartDownloadClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .padding(start = 24.dp, top = 12.dp, end = 12.dp, bottom = 12.dp) + ) { + StickerPackInfo( + coverImageUri = DecryptableUri(pack.record.cover.uri), + title = pack.record.title, + author = pack.record.author.nullIfBlank(), + showOfficialBadge = pack.isBlessed, + modifier = Modifier.weight(1f) + ) + + // TODO show TransferProgressIndicator based on download state + IconButtons.IconButton( + size = 48.dp, + onClick = onStartDownloadClick, + content = { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_arrow_circle_down_24), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + contentDescription = stringResource(R.string.StickerManagement_accessibility_download_pack, pack.record.title) + ) + } + ) + } +} + +@Composable +fun InstalledStickerPackRow( + pack: InstalledStickerPack, + multiSelectModeEnabled: Boolean = false, + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background(MaterialTheme.colorScheme.surface) + .padding(12.dp) + ) { + if (multiSelectModeEnabled) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.padding(end = 8.dp) + ) + } + + StickerPackInfo( + coverImageUri = DecryptableUri(pack.record.cover.uri), + title = pack.record.title, + author = pack.record.author.nullIfBlank(), + showOfficialBadge = pack.isBlessed, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_drag_handle), + contentDescription = stringResource(R.string.StickerManagement_accessibility_drag_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .padding(horizontal = 12.dp) + .size(24.dp) + ) + } +} + +@Composable +private fun StickerPackInfo( + coverImageUri: DecryptableUri, + title: String, + author: String?, + showOfficialBadge: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth() + ) { + GlideImage( + model = coverImageUri, + modifier = Modifier + .padding(end = 16.dp) + .size(56.dp) + ) + + Column( + modifier = Modifier.align(Alignment.CenterVertically) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + if (showOfficialBadge) { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_official_20), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 4.dp) + .size(16.dp) + ) + } + } + Text( + text = author ?: stringResource(R.string.StickerManagement_author_unknown), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@SignalPreview +@Composable +private fun StickerPackSectionHeaderPreview() = SignalTheme { + StickerPackSectionHeader( + text = "Signal artist series" + ) +} + +@SignalPreview +@Composable +private fun AvailableStickerPackRowPreviewBlessed() = SignalTheme { + AvailableStickerPackRow( + pack = StickerPreviewDataFactory.availablePack( + title = "Swoon / Faces", + author = "Swoon", + isBlessed = true + ) + ) +} + +@SignalPreview +@Composable +private fun AvailableStickerPackRowPreviewNotBlessed() = SignalTheme { + AvailableStickerPackRow( + pack = StickerPreviewDataFactory.availablePack( + title = "Day by Day", + author = "Miguel Ángel Camprubí", + isBlessed = false, + downloadStatus = DownloadStatus.NotDownloaded + ) + ) +} + +@SignalPreview +@Composable +private fun AvailableStickerPackRowPreviewDownloading() = SignalTheme { + AvailableStickerPackRow( + pack = StickerPreviewDataFactory.availablePack( + title = "Bandit the Cat", + author = "Agnes Lee", + isBlessed = false, + downloadStatus = DownloadStatus.InProgress(progressPercent = 22.0) + ) + ) +} + +@SignalPreview +@Composable +private fun AvailableStickerPackRowPreviewDownloaded() = SignalTheme { + AvailableStickerPackRow( + pack = StickerPreviewDataFactory.availablePack( + title = "Bandit the Cat", + author = "Agnes Lee", + isBlessed = false, + downloadStatus = DownloadStatus.Downloaded + ) + ) +} + +@SignalPreview +@Composable +private fun InstalledStickerPackRowPreview() = SignalTheme { + InstalledStickerPackRow( + multiSelectModeEnabled = false, + pack = StickerPreviewDataFactory.installedPack( + title = "Bandit the Cat", + author = "Agnes Lee", + isBlessed = true + ) + ) +} + +@SignalPreview +@Composable +private fun InstalledStickerPackRowSelectModePreview() = SignalTheme { + InstalledStickerPackRow( + multiSelectModeEnabled = true, + pack = StickerPreviewDataFactory.installedPack( + title = "Bandit the Cat", + author = "Agnes Lee", + isBlessed = true + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java index 4717ca5b4b..88c60b42be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java @@ -177,7 +177,7 @@ public final class StickerPackPreviewActivity extends PassphraseRequiredActivity private void initViewModel(@NonNull String packId, @NonNull String packKey) { viewModel = new ViewModelProvider(this, new StickerPackPreviewViewModel.Factory(getApplication(), new StickerPackPreviewRepository(this), - new StickerManagementRepository(this)) + new StickerManagementRepository()) ).get(StickerPackPreviewViewModel.class); viewModel.getStickerManifest(packId, packKey).observe(this, manifest -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt new file mode 100644 index 0000000000..68fde81911 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPreviewDataFactory.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.stickers + +import org.thoughtcrime.securesms.database.model.StickerPackRecord +import org.thoughtcrime.securesms.database.model.StickerRecord +import java.util.UUID + +/** + * Generates sample sticker data to use in compose UI previews. + */ +object StickerPreviewDataFactory { + fun availablePack( + packId: String = UUID.randomUUID().toString(), + title: String, + author: String, + isBlessed: Boolean = false, + downloadStatus: AvailableStickerPack.DownloadStatus = AvailableStickerPack.DownloadStatus.NotDownloaded + ): AvailableStickerPack = AvailableStickerPack( + record = StickerPackRecord( + packId = packId, + packKey = "packKey", + title = title, + author = author, + cover = StickerRecord( + rowId = 11, + packId = packId, + packKey = "packKey", + stickerId = 111, + emoji = "", + contentType = "image/webp", + size = 1111, + isCover = true + ), + isInstalled = false + ), + isBlessed = isBlessed, + downloadStatus = downloadStatus + ) + + fun installedPack( + packId: String = UUID.randomUUID().toString(), + title: String, + author: String, + isBlessed: Boolean = false + ): InstalledStickerPack = InstalledStickerPack( + record = StickerPackRecord( + packId = packId, + packKey = "packKey", + title = title, + author = author, + cover = StickerRecord( + rowId = 11, + packId = packId, + packKey = "packKey", + stickerId = 111, + emoji = "", + contentType = "image/webp", + size = 1111, + isCover = true + ), + isInstalled = true + ), + isBlessed = isBlessed, + sortOrder = 0 + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7502b094bf..3b72bffa3b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2790,16 +2790,23 @@ No sticker packs are available No sticker packs are installed + + Unknown + + Installed stickers + + Stickers you received + + Signal artist series + + Download %s sticker pack + + Drag and drop handle - Installed Stickers - Stickers You Received - Signal Artist Series No stickers installed Stickers from incoming messages will appear here Untitled - - Unknown Untitled