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
new file mode 100644
index 0000000000..6b47f099b4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressIndicator.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2025 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.transfercontrols
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.clickableContainer
+import org.thoughtcrime.securesms.R
+
+/**
+ * A button that can be used to start, cancel, show progress, and show completion of a data transfer.
+ */
+@Composable
+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)
+ }
+}
+
+@Composable
+private fun StartTransferButton(
+ state: TransferProgressState.Ready,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .clickableContainer(
+ contentDescription = state.startButtonContentDesc,
+ onClickLabel = state.startButtonOnClickLabel,
+ onClick = state.onStartClick
+ )
+ ) {
+ Icon(
+ painter = state.iconPainter,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null,
+ modifier = Modifier
+ .matchParentSize()
+ .padding(12.dp)
+ )
+ }
+}
+
+@Composable
+private fun ProgressIndicator(
+ state: TransferProgressState.InProgress,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .clickableContainer(
+ contentDescription = null,
+ onClickLabel = state.cancelButtonOnClickLabel,
+ onClick = state.onCancelClick
+ )
+ .padding(8.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.symbol_stop_24),
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null,
+ modifier = Modifier
+ .matchParentSize()
+ .padding(6.dp)
+ )
+
+ CircularProgressIndicator(
+ progress = { state.progress },
+ strokeWidth = 2.dp,
+ strokeCap = StrokeCap.Round,
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .matchParentSize()
+ .clearAndSetSemantics {
+ contentDescription = state.cancelButtonContentDesc
+ }
+ )
+ }
+}
+
+@Composable
+private fun CompleteIcon(
+ state: TransferProgressState.Complete,
+ modifier: Modifier = Modifier
+) {
+ Icon(
+ painter = state.iconPainter,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = state.iconContentDesc,
+ modifier = modifier.padding(12.dp)
+ )
+}
+
+sealed interface TransferProgressState {
+ data class Ready(
+ val iconPainter: Painter,
+ val startButtonContentDesc: String,
+ val startButtonOnClickLabel: String,
+ val onStartClick: () -> Unit
+ ) : TransferProgressState
+
+ data class InProgress(
+ val progress: Float,
+ val cancelButtonContentDesc: String,
+ val cancelButtonOnClickLabel: String,
+ val onCancelClick: () -> Unit = {}
+ ) : TransferProgressState
+
+ data class Complete(
+ val iconPainter: Painter,
+ val iconContentDesc: String
+ ) : TransferProgressState
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt
index 0998e45efb..5b5d77a28b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/transfercontrols/TransferProgressView.kt
@@ -15,6 +15,7 @@ import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
+import androidx.annotation.Discouraged
import androidx.core.content.ContextCompat
import androidx.core.graphics.withTranslation
import org.signal.core.util.logging.Log
@@ -24,6 +25,7 @@ import kotlin.math.roundToInt
/**
* This displays a circular progress around an icon. The icon is either an upload arrow, a download arrow, or a rectangular stop button.
*/
+@Discouraged("Use TransferProgressIndicator instead.")
class TransferProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
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 7674cecfb7..dd6a67c76c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivityV2.kt
@@ -279,7 +279,7 @@ private fun AvailableStickersContentPreview() {
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = false,
- downloadStatus = DownloadStatus.InProgress(progressPercent = 22.0)
+ downloadStatus = DownloadStatus.InProgress(progress = 0.37f)
),
StickerPreviewDataFactory.availablePack(
title = "Day by Day",
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 1bd5c757f2..5d673accf2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementViewModelV2.kt
@@ -52,7 +52,7 @@ data class AvailableStickerPack(
) {
sealed class DownloadStatus {
data object NotDownloaded : DownloadStatus()
- data class InProgress(val progressPercent: Double) : DownloadStatus()
+ data class InProgress(val progress: Float) : DownloadStatus()
data object Downloaded : DownloadStatus()
}
}
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 c3fb7fb68c..b46e27ccf3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackListItems.kt
@@ -19,16 +19,17 @@ 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.painterResource
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.components.transfercontrols.TransferProgressIndicator
+import org.thoughtcrime.securesms.components.transfercontrols.TransferProgressState
import org.thoughtcrime.securesms.compose.GlideImage
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri
import org.thoughtcrime.securesms.stickers.AvailableStickerPack.DownloadStatus
@@ -53,6 +54,7 @@ fun StickerPackSectionHeader(
fun AvailableStickerPackRow(
pack: AvailableStickerPack,
onStartDownloadClick: () -> Unit = {},
+ onCancelDownloadClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Row(
@@ -69,15 +71,25 @@ fun AvailableStickerPackRow(
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)
+ TransferProgressIndicator(
+ state = when (pack.downloadStatus) {
+ DownloadStatus.NotDownloaded -> TransferProgressState.Ready(
+ iconPainter = painterResource(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
+ )
+
+ is DownloadStatus.InProgress -> TransferProgressState.InProgress(
+ progress = pack.downloadStatus.progress,
+ cancelButtonContentDesc = stringResource(R.string.StickerManagement_accessibility_cancel),
+ cancelButtonOnClickLabel = stringResource(R.string.StickerManagement_accessibility_cancel_downloading_pack, pack.record.title),
+ onCancelClick = onCancelDownloadClick
+ )
+
+ DownloadStatus.Downloaded -> TransferProgressState.Complete(
+ iconPainter = painterResource(id = R.drawable.symbol_check_24),
+ iconContentDesc = stringResource(R.string.StickerManagement_accessibility_downloaded_checkmark, pack.record.title)
)
}
)
@@ -215,7 +227,7 @@ private fun AvailableStickerPackRowPreviewDownloading() = SignalTheme {
title = "Bandit the Cat",
author = "Agnes Lee",
isBlessed = false,
- downloadStatus = DownloadStatus.InProgress(progressPercent = 22.0)
+ downloadStatus = DownloadStatus.InProgress(progress = 0.37f)
)
)
}
diff --git a/app/src/main/res/drawable/symbol_stop_24.xml b/app/src/main/res/drawable/symbol_stop_24.xml
new file mode 100644
index 0000000000..0c17cdd64a
--- /dev/null
+++ b/app/src/main/res/drawable/symbol_stop_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3b72bffa3b..4e9cd86a04 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2800,8 +2800,16 @@
Signal artist series
Download %s sticker pack
+
+ Cancel downloading %s sticker pack
+
+ Downloaded checkmark
Drag and drop handle
+
+ Download
+
+ Cancel
No stickers installed
diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt b/core-ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
index fe07bf9c62..750b5a6ba9 100644
--- a/core-ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
+++ b/core-ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
@@ -5,10 +5,18 @@
package org.signal.core.ui.compose
+import androidx.compose.foundation.Indication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import org.signal.core.ui.R
@@ -21,3 +29,34 @@ fun Modifier.horizontalGutters(
): Modifier {
return padding(horizontal = gutterSize)
}
+
+/**
+ * Configures a component to be clickable within its bounds and show a default indication when pressed.
+ *
+ * This modifier is designed for use on container components, making it easier to create a clickable container with proper accessibility configuration.
+ */
+@Composable
+fun Modifier.clickableContainer(
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ indication: Indication = ripple(bounded = false),
+ enabled: Boolean = true,
+ contentDescription: String?,
+ onClickLabel: String,
+ role: Role? = null,
+ onClick: () -> Unit
+): Modifier = clickable(
+ interactionSource = interactionSource,
+ indication = indication,
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ role = role,
+ onClick = onClick
+).then(
+ if (contentDescription != null) {
+ Modifier.semantics(mergeDescendants = true) {
+ this.contentDescription = contentDescription
+ }
+ } else {
+ Modifier
+ }
+)