Add TransferProgressIndicator composable.

Adds a composable version of `TransferProgressView`.
This commit is contained in:
Jeffrey Starke
2025-04-17 11:11:30 -04:00
committed by Cody Henthorne
parent 3d1895500c
commit 48d26beb77
8 changed files with 218 additions and 14 deletions

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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()
}
}

View File

@@ -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)
)
)
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.5,4.5C5.395,4.5 4.5,5.395 4.5,6.5V17.5C4.5,18.605 5.395,19.5 6.5,19.5H17.5C18.605,19.5 19.5,18.605 19.5,17.5V6.5C19.5,5.395 18.605,4.5 17.5,4.5H6.5Z"
android:fillColor="#000000"/>
</vector>

View File

@@ -2800,8 +2800,16 @@
<string name="StickerManagement_signal_artist_series_header">Signal artist series</string>
<!-- Accessibility label for the button to download a sticker pack. The placeholder is the sticker pack name. -->
<string name="StickerManagement_accessibility_download_pack">Download %s sticker pack</string>
<!-- Accessibility label for the button to cancel a sticker pack download. The placeholder is the sticker pack name. -->
<string name="StickerManagement_accessibility_cancel_downloading_pack">Cancel downloading %s sticker pack</string>
<!-- Accessibility label for the icon that is displayed after a sticker pack has been downloaded. The placeholder is the sticker pack name. -->
<string name="StickerManagement_accessibility_downloaded_checkmark">Downloaded checkmark</string>
<!-- Accessibility label for the sticker pack drag and drop handle used to reorder the list. -->
<string name="StickerManagement_accessibility_drag_handle">Drag and drop handle</string>
<!-- Accessibility label for the button to download a sticker pack. -->
<string name="StickerManagement_accessibility_download">Download</string>
<!-- Accessibility label for the button to cancel downloading a sticker pack. -->
<string name="StickerManagement_accessibility_cancel">Cancel</string>
<!-- StickerManagementAdapter -->
<string name="StickerManagementAdapter_no_stickers_installed">No stickers installed</string>

View File

@@ -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
}
)