Sticker management v2 - Implement sticker pack installation.

Adds the ability to install sticker packs using `StickerManagementActivityV2`.

When the install button is clicked, it will morph into an indeterminate progress bar, which will then animate into a checkmark once the installation completes successfully. Then a couple seconds later, the sticker pack row will be removed from the available sticker packs list.
This commit is contained in:
Jeffrey Starke
2025-04-22 11:13:01 -04:00
committed by Cody Henthorne
parent 9f40bfc645
commit a5f766a333
5 changed files with 155 additions and 45 deletions

View File

@@ -5,6 +5,14 @@
package org.thoughtcrime.securesms.components.transfercontrols
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -28,10 +36,22 @@ fun TransferProgressIndicator(
state: TransferProgressState,
modifier: Modifier = Modifier.size(48.dp)
) {
when (state) {
is TransferProgressState.Ready -> StartTransferButton(state, modifier)
is TransferProgressState.InProgress -> ProgressIndicator(state, modifier)
is TransferProgressState.Complete -> CompleteIcon(state, modifier)
AnimatedContent(
targetState = state,
transitionSpec = {
val startDelay = 200
val enterTransition = fadeIn(tween(delayMillis = startDelay, durationMillis = 500)) + scaleIn(tween(delayMillis = startDelay, durationMillis = 400))
val exitTransition = fadeOut(tween(delayMillis = startDelay, durationMillis = 600)) + scaleOut(tween(delayMillis = startDelay, durationMillis = 800))
enterTransition
.togetherWith(exitTransition)
.using(SizeTransform(clip = false))
}
) { targetState ->
when (targetState) {
is TransferProgressState.Ready -> StartTransferButton(targetState, modifier)
is TransferProgressState.InProgress -> ProgressIndicator(targetState, modifier)
is TransferProgressState.Complete -> CompleteIcon(targetState, modifier)
}
}
}

View File

@@ -36,7 +36,10 @@ 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.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
@@ -62,13 +65,20 @@ class StickerManagementActivityV2 : PassphraseRequiredActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.onScreenVisible()
}
}
setContent {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SignalTheme {
StickerManagementScreen(
uiState = uiState,
onNavigateBack = ::supportFinishAfterTransition
onNavigateBack = ::supportFinishAfterTransition,
onInstallClick = viewModel::installStickerPack
)
}
}
@@ -85,6 +95,7 @@ private data class Page(
private fun StickerManagementScreen(
uiState: StickerManagementUiState,
onNavigateBack: () -> Unit = {},
onInstallClick: (AvailableStickerPack) -> Unit = {},
modifier: Modifier = Modifier
) {
Scaffold(
@@ -94,7 +105,13 @@ private fun StickerManagementScreen(
val pages = listOf(
Page(
title = stringResource(R.string.StickerManagement_available_tab_label),
getContent = { AvailableStickersContent(blessedPacks = uiState.availableBlessedPacks, availablePacks = uiState.availablePacks) }
getContent = {
AvailableStickersContent(
blessedPacks = uiState.availableBlessedPacks,
notBlessedPacks = uiState.availableNotBlessedPacks,
onInstallClick = onInstallClick
)
}
),
Page(
title = stringResource(R.string.StickerManagement_installed_tab_label),
@@ -175,10 +192,11 @@ private fun PagerTab(
@Composable
private fun AvailableStickersContent(
blessedPacks: List<AvailableStickerPack>,
availablePacks: List<AvailableStickerPack>,
notBlessedPacks: List<AvailableStickerPack>,
onInstallClick: (AvailableStickerPack) -> Unit = {},
modifier: Modifier = Modifier
) {
if (blessedPacks.isEmpty() && availablePacks.isEmpty()) {
if (blessedPacks.isEmpty() && notBlessedPacks.isEmpty()) {
EmptyView(text = stringResource(R.string.StickerManagement_available_tab_empty_text))
} else {
LazyColumn(
@@ -191,21 +209,29 @@ private fun AvailableStickersContent(
items = blessedPacks,
key = { it.record.packId }
) {
AvailableStickerPackRow(it)
AvailableStickerPackRow(
pack = it,
onInstallClick = { onInstallClick(it) },
modifier = Modifier.animateItem()
)
}
}
if (blessedPacks.isNotEmpty() && availablePacks.isNotEmpty()) {
if (blessedPacks.isNotEmpty() && notBlessedPacks.isNotEmpty()) {
item { Dividers.Default() }
}
if (availablePacks.isNotEmpty()) {
if (notBlessedPacks.isNotEmpty()) {
item { StickerPackSectionHeader(text = stringResource(R.string.StickerManagement_stickers_you_received_header)) }
items(
items = availablePacks,
items = notBlessedPacks,
key = { it.record.packId }
) {
AvailableStickerPackRow(it)
AvailableStickerPackRow(
pack = it,
onInstallClick = { onInstallClick(it) },
modifier = Modifier.animateItem()
)
}
}
}
@@ -254,10 +280,7 @@ private fun EmptyView(
private fun StickerManagementScreenEmptyStatePreview() {
Previews.Preview {
StickerManagementScreen(
StickerManagementUiState(
availablePacks = emptyList(),
installedPacks = emptyList()
)
StickerManagementUiState()
)
}
}
@@ -274,7 +297,7 @@ private fun AvailableStickersContentPreview() {
isBlessed = true
)
),
availablePacks = listOf(
notBlessedPacks = listOf(
StickerPreviewDataFactory.availablePack(
title = "Bandit the Cat",
author = "Agnes Lee",

View File

@@ -69,19 +69,22 @@ object StickerManagementRepository {
val installedPacks = mutableListOf<StickerPackRecord>()
val availablePacks = mutableListOf<StickerPackRecord>()
val blessedPacks = mutableListOf<StickerPackRecord>()
val sortOrderById = mutableMapOf<String, Int>()
reader.asSequence().forEach { record ->
reader.asSequence().forEachIndexed { index, record ->
when {
record.isInstalled -> installedPacks.add(record)
BlessedPacks.contains(record.packId) -> blessedPacks.add(record)
else -> availablePacks.add(record)
}
sortOrderById[record.packId] = index
}
StickerPackResult(
installedPacks = installedPacks,
availablePacks = availablePacks,
blessedPacks = blessedPacks
blessedPacks = blessedPacks,
sortOrderByPackId = sortOrderById
)
}
}
@@ -159,5 +162,6 @@ object StickerManagementRepository {
data class StickerPackResult(
val installedPacks: List<StickerPackRecord>,
val availablePacks: List<StickerPackRecord>,
val blessedPacks: List<StickerPackRecord>
val blessedPacks: List<StickerPackRecord>,
val sortOrderByPackId: Map<String, Int>
)

View File

@@ -7,10 +7,13 @@ package org.thoughtcrime.securesms.stickers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.database.model.StickerPackRecord
import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus
@@ -21,32 +24,82 @@ class StickerManagementViewModelV2 : ViewModel() {
private val _uiState = MutableStateFlow(StickerManagementUiState())
val uiState: StateFlow<StickerManagementUiState> = _uiState.asStateFlow()
private val downloadStatusByPackId: MutableStateFlow<Map<String, DownloadStatus>> = MutableStateFlow(emptyMap())
init {
viewModelScope.launch {
stickerManagementRepo.deleteOrphanedStickerPacks()
stickerManagementRepo.fetchUnretrievedReferencePacks()
loadStickerPacks()
}
}
fun onScreenVisible() {
viewModelScope.launch {
stickerManagementRepo.deleteOrphanedStickerPacks()
}
}
private suspend fun loadStickerPacks() {
StickerManagementRepository.getStickerPacks()
.collectLatest { 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) }
)
combine(stickerManagementRepo.getStickerPacks(), downloadStatusByPackId, ::Pair)
.collectLatest { (stickerPacksResult, downloadStatuses) ->
val recentlyInstalledPacks = stickerPacksResult.installedPacks.filter { downloadStatuses.contains(it.packId) }
val allAvailablePacks = (stickerPacksResult.blessedPacks + stickerPacksResult.availablePacks + recentlyInstalledPacks)
.map { record ->
AvailableStickerPack(
record = record,
isBlessed = BlessedPacks.contains(record.packId),
downloadStatus = downloadStatuses.getOrElse(record.packId) {
downloadStatusByPackId.value.getOrDefault(record.packId, DownloadStatus.NotDownloaded)
}
)
}
.sortedBy { stickerPacksResult.sortOrderByPackId.getValue(it.record.packId) }
val (availableBlessedPacks, availableNotBlessedPacks) = allAvailablePacks.partition { it.isBlessed }
val installedPacks = stickerPacksResult.installedPacks.map { record ->
InstalledStickerPack(
record = record,
isBlessed = BlessedPacks.contains(record.packId),
sortOrder = stickerPacksResult.sortOrderByPackId.getValue(record.packId)
)
}
_uiState.update { previousState ->
previousState.copy(
availableBlessedPacks = availableBlessedPacks,
availableNotBlessedPacks = availableNotBlessedPacks,
installedPacks = installedPacks
)
}
}
}
fun installStickerPack(pack: AvailableStickerPack) = viewModelScope.launch {
updatePackDownloadStatus(pack.record.packId, DownloadStatus.InProgress)
StickerManagementRepository.installStickerPack(packId = pack.record.packId, packKey = pack.record.packKey, notify = true)
updatePackDownloadStatus(pack.record.packId, DownloadStatus.Downloaded)
delay(1500) // wait, so we show the downloaded status for a bit before removing this row from the available sticker packs list
updatePackDownloadStatus(pack.record.packId, null)
}
private fun updatePackDownloadStatus(packId: String, newStatus: DownloadStatus?) {
downloadStatusByPackId.value = if (newStatus == null) {
downloadStatusByPackId.value.minus(packId)
} else {
downloadStatusByPackId.value.plus(packId to newStatus)
}
}
fun uninstallStickerPack(pack: AvailableStickerPack) = viewModelScope.launch {
StickerManagementRepository.uninstallStickerPack(packId = pack.record.packId, packKey = pack.record.packKey)
}
}
data class StickerManagementUiState(
val availableBlessedPacks: List<AvailableStickerPack> = emptyList(),
val availablePacks: List<AvailableStickerPack> = emptyList(),
val availableNotBlessedPacks: List<AvailableStickerPack> = emptyList(),
val installedPacks: List<InstalledStickerPack> = emptyList(),
val isMultiSelectMode: Boolean = false
)

View File

@@ -17,6 +17,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -52,7 +53,7 @@ fun StickerPackSectionHeader(
@Composable
fun AvailableStickerPackRow(
pack: AvailableStickerPack,
onStartDownloadClick: () -> Unit = {},
onInstallClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Row(
@@ -69,23 +70,32 @@ fun AvailableStickerPackRow(
modifier = Modifier.weight(1f)
)
TransferProgressIndicator(
state = when (pack.downloadStatus) {
DownloadStatus.NotDownloaded -> TransferProgressState.Ready(
icon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_circle_down_24),
startButtonContentDesc = stringResource(R.string.StickerManagement_accessibility_download),
startButtonOnClickLabel = stringResource(R.string.StickerManagement_accessibility_download_pack, pack.record.title),
onStartClick = onStartDownloadClick
val readyIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_circle_down_24)
val downloadedIcon = ImageVector.vectorResource(R.drawable.symbol_check_24)
val startButtonContentDesc = stringResource(R.string.StickerManagement_accessibility_download)
val startButtonOnClickLabel = stringResource(R.string.StickerManagement_accessibility_download_pack, pack.record.title)
val downloadedContentDesc = stringResource(R.string.StickerManagement_accessibility_downloaded_checkmark, pack.record.title)
val transferState = remember(pack.downloadStatus) {
when (pack.downloadStatus) {
is DownloadStatus.NotDownloaded -> TransferProgressState.Ready(
icon = readyIcon,
startButtonContentDesc = startButtonContentDesc,
startButtonOnClickLabel = startButtonOnClickLabel,
onStartClick = onInstallClick
)
is DownloadStatus.InProgress -> TransferProgressState.InProgress()
DownloadStatus.Downloaded -> TransferProgressState.Complete(
icon = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
iconContentDesc = stringResource(R.string.StickerManagement_accessibility_downloaded_checkmark, pack.record.title)
is DownloadStatus.Downloaded -> TransferProgressState.Complete(
icon = downloadedIcon,
iconContentDesc = downloadedContentDesc
)
}
)
}
TransferProgressIndicator(state = transferState)
}
}