diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt index 21fd9e7f95..81fe04d697 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt @@ -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) + } } } 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 00e577da72..9b220ea70e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt @@ -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, - availablePacks: List, + notBlessedPacks: List, + 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", diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.kt index 3b38b83538..2fae26c79c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementRepository.kt @@ -69,19 +69,22 @@ object StickerManagementRepository { val installedPacks = mutableListOf() val availablePacks = mutableListOf() val blessedPacks = mutableListOf() + val sortOrderById = mutableMapOf() - 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, val availablePacks: List, - val blessedPacks: List + val blessedPacks: List, + val sortOrderByPackId: Map ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt index c8d988e751..045ad41516 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt @@ -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 = _uiState.asStateFlow() + private val downloadStatusByPackId: MutableStateFlow> = 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 = emptyList(), - val availablePacks: List = emptyList(), + val availableNotBlessedPacks: List = emptyList(), val installedPacks: List = emptyList(), val isMultiSelectMode: 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 index ff63b4d942..1e697dbbeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt @@ -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) } }