mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-08 22:34:12 +01:00
Convert TransferControlView rendering to compose.
This commit is contained in:
@@ -21,6 +21,7 @@ import com.bumptech.glide.RequestManager;
|
||||
import org.signal.core.ui.view.Stub;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
@@ -263,7 +264,7 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
private void showSlides(@NonNull RequestManager requestManager, @NonNull List<Slide> slides) {
|
||||
boolean showControls = TransferControlView.containsPlayableSlides(slides);
|
||||
boolean showControls = TransferControls.containsPlayableSlides(slides);
|
||||
setSlide(requestManager, slides.get(0), R.id.album_cell_1, showControls);
|
||||
setSlide(requestManager, slides.get(1), R.id.album_cell_2, showControls);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.glide.targets.GlideBitmapListeningTarget;
|
||||
import org.thoughtcrime.securesms.glide.targets.GlideDrawableListeningTarget;
|
||||
@@ -384,7 +385,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
transferControlViewStub.get().setSlides(List.of(slide));
|
||||
}
|
||||
int transferState = TransferControlView.getTransferState(List.of(slide));
|
||||
int transferState = TransferControls.getTransferState(List.of(slide));
|
||||
boolean isOffloadedImage = (transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && MediaUtil.isImageType(slide.getContentType())) && AttachmentUtil.isRestoreOnOpenPermitted(getContext(), slide.asAttachment());
|
||||
|
||||
if (!showControls ||
|
||||
|
||||
+70
-643
@@ -5,55 +5,70 @@
|
||||
package org.thoughtcrime.securesms.components.transfercontrols
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.StaticLayout
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.ByteSize
|
||||
import org.signal.core.util.ThrottledDebouncer
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewParentTransitionController
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.databinding.TransferControlsViewBinding
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.UUID
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
private val uuid = UUID.randomUUID().toString()
|
||||
private val binding: TransferControlsViewBinding
|
||||
/**
|
||||
* Displays the start/cancel/progress controls that overlay an attachment thumbnail.
|
||||
*/
|
||||
class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AbstractComposeView(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TransferControlView::class.java)
|
||||
|
||||
/** Flip to true locally to trace a single view's render transitions and ignored progress events. */
|
||||
private const val VERBOSE_DEVELOPMENT_LOGGING = false
|
||||
}
|
||||
|
||||
private var state = TransferControlViewState()
|
||||
private val progressUpdateDebouncer: ThrottledDebouncer = ThrottledDebouncer(100)
|
||||
|
||||
private var mode: Mode = Mode.GONE
|
||||
/** Throttled observable flow of [state] */
|
||||
private var renderState by mutableStateOf<TransferControlsRenderState>(TransferControlsRenderState.Gone)
|
||||
|
||||
private val progressUpdateDebouncer = ThrottledDebouncer(100)
|
||||
|
||||
/** Per-instance id so a single recycled view can be isolated in logcat when [VERBOSE_DEVELOPMENT_LOGGING] is on. */
|
||||
private val viewId by lazy { UUID.randomUUID().toString().take(8) }
|
||||
|
||||
init {
|
||||
tag = uuid
|
||||
binding = TransferControlsViewBinding.inflate(LayoutInflater.from(context), this)
|
||||
visibility = GONE
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
|
||||
isLongClickable = false
|
||||
|
||||
addOnAttachStateChangeListener(RecyclerViewParentTransitionController(child = this))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
SignalTheme {
|
||||
TransferControls(
|
||||
state = renderState,
|
||||
onStartClick = { state.startTransferClickListener?.onClick(this) },
|
||||
onCancelClick = { state.cancelTransferClickedListener?.onClick(this) },
|
||||
onPlayClick = { state.instantPlaybackClickListener?.onClick(this) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
|
||||
@@ -64,466 +79,34 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
EventBus.getDefault().unregister(this)
|
||||
}
|
||||
|
||||
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
|
||||
val newState = stateFactory.invoke(state)
|
||||
val oldMode = deriveMode(state)
|
||||
val newMode = deriveMode(newState)
|
||||
if ((newState != state || oldMode != newMode) && !(oldMode == Mode.GONE && newMode == Mode.GONE)) {
|
||||
progressUpdateDebouncer.publish {
|
||||
applyState(newState)
|
||||
}
|
||||
}
|
||||
state = newState
|
||||
}
|
||||
|
||||
fun isGone(): Boolean {
|
||||
return mode == Mode.GONE
|
||||
return TransferControls.deriveRenderState(state) is TransferControlsRenderState.Gone
|
||||
}
|
||||
|
||||
private fun applyState(currentState: TransferControlViewState) {
|
||||
val mode = deriveMode(currentState)
|
||||
verboseLog("New state applying, mode = $mode")
|
||||
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
|
||||
val newState = stateFactory(state)
|
||||
|
||||
children.forEach {
|
||||
it.clearAnimation()
|
||||
}
|
||||
val oldRender = TransferControls.deriveRenderState(state)
|
||||
val newRender = TransferControls.deriveRenderState(newState)
|
||||
state = newState
|
||||
|
||||
when (mode) {
|
||||
Mode.PENDING_GALLERY -> displayPendingGallery(currentState)
|
||||
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> displayPendingGalleryWithPlayable(currentState)
|
||||
Mode.PENDING_SINGLE_ITEM -> displayPendingSingleItem(currentState)
|
||||
Mode.PENDING_VIDEO_PLAYABLE -> displayPendingPlayableVideo(currentState)
|
||||
Mode.DOWNLOADING_GALLERY -> displayDownloadingGallery(currentState)
|
||||
Mode.DOWNLOADING_SINGLE_ITEM -> displayDownloadingSingleItem(currentState)
|
||||
Mode.DOWNLOADING_VIDEO_PLAYABLE -> displayDownloadingPlayableVideo(currentState)
|
||||
Mode.UPLOADING_GALLERY -> displayUploadingGallery(currentState)
|
||||
Mode.UPLOADING_SINGLE_ITEM -> displayUploadingSingleItem(currentState)
|
||||
Mode.RETRY_DOWNLOADING -> displayRetry(currentState, false)
|
||||
Mode.RETRY_UPLOADING -> displayRetry(currentState, true)
|
||||
Mode.GONE -> displayChildrenAsGone()
|
||||
}
|
||||
this.mode = mode
|
||||
}
|
||||
|
||||
private fun deriveMode(currentState: TransferControlViewState): Mode {
|
||||
if (currentState.slides.isEmpty()) {
|
||||
verboseLog("Setting empty slide deck to GONE")
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
if (currentState.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
|
||||
verboseLog("Setting slide deck that's finished to GONE\n\t${slidesAsListOfTimestamps(currentState.slides)}")
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
if (currentState.isVisible) {
|
||||
if (currentState.slides.size == 1) {
|
||||
val slide = currentState.slides.first()
|
||||
if (slide.hasVideo()) {
|
||||
if (currentState.isUpload) {
|
||||
return when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
Mode.UPLOADING_SINGLE_ITEM
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
|
||||
Mode.PENDING_SINGLE_ITEM
|
||||
}
|
||||
|
||||
else -> {
|
||||
Mode.RETRY_UPLOADING
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
if (currentState.playableWhileDownloading) {
|
||||
Mode.DOWNLOADING_VIDEO_PLAYABLE
|
||||
} else {
|
||||
Mode.DOWNLOADING_SINGLE_ITEM
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
|
||||
Mode.RETRY_DOWNLOADING
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (currentState.playableWhileDownloading) {
|
||||
Mode.PENDING_VIDEO_PLAYABLE
|
||||
} else {
|
||||
Mode.PENDING_SINGLE_ITEM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return if (currentState.isUpload) {
|
||||
when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
|
||||
Mode.RETRY_UPLOADING
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
|
||||
Mode.PENDING_SINGLE_ITEM
|
||||
}
|
||||
|
||||
else -> {
|
||||
Mode.UPLOADING_SINGLE_ITEM
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
Mode.DOWNLOADING_SINGLE_ITEM
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
|
||||
Mode.RETRY_DOWNLOADING
|
||||
}
|
||||
|
||||
else -> {
|
||||
Mode.PENDING_SINGLE_ITEM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (getTransferState(currentState.slides)) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
return if (currentState.isUpload) {
|
||||
Mode.UPLOADING_GALLERY
|
||||
} else {
|
||||
Mode.DOWNLOADING_GALLERY
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
|
||||
return if (containsPlayableSlides(currentState.slides)) {
|
||||
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE
|
||||
} else {
|
||||
Mode.PENDING_GALLERY
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
|
||||
return if (currentState.isUpload) {
|
||||
Mode.RETRY_UPLOADING
|
||||
} else {
|
||||
Mode.RETRY_DOWNLOADING
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_DONE -> {
|
||||
verboseLog("[Case 2] Setting slide deck that's finished to GONE\t${slidesAsListOfTimestamps(currentState.slides)}")
|
||||
return Mode.GONE
|
||||
}
|
||||
if (oldRender != newRender) {
|
||||
verboseLog { "render $oldRender -> $newRender slides=[${slidesAsLogString(newState.slides)}]" }
|
||||
progressUpdateDebouncer.publish {
|
||||
renderState = newRender
|
||||
if (newRender !is TransferControlsRenderState.Gone) {
|
||||
visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
verboseLog("Setting slide deck to GONE because isVisible is false:\t${slidesAsListOfTimestamps(currentState.slides)}")
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
Log.i(TAG, "[$uuid] Hit default mode case, this should not happen.")
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
private fun displayPendingGallery(currentState: TransferControlViewState) {
|
||||
binding.primaryProgressView.startClickListener = currentState.startTransferClickListener
|
||||
applyFocusableAndClickable(
|
||||
currentState,
|
||||
listOf(binding.primaryProgressView, binding.primaryDetailsText, binding.primaryBackground),
|
||||
listOf(binding.secondaryProgressView, binding.playVideoButton)
|
||||
)
|
||||
binding.primaryProgressView.setStopped(false)
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
secondaryProgressView = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
binding.primaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
|
||||
binding.primaryBackground.setOnClickListener(currentState.startTransferClickListener)
|
||||
|
||||
binding.primaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
|
||||
ViewUtil.dpToPx(-PRIMARY_TEXT_OFFSET_DP).toFloat()
|
||||
} else {
|
||||
ViewUtil.dpToPx(PRIMARY_TEXT_OFFSET_DP).toFloat()
|
||||
}
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayPendingGalleryWithPlayable(currentState: TransferControlViewState) {
|
||||
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
|
||||
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
|
||||
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
|
||||
super.setClickable(false)
|
||||
binding.secondaryProgressView.isClickable = currentState.showSecondaryText
|
||||
binding.secondaryProgressView.isFocusable = currentState.showSecondaryText
|
||||
binding.secondaryDetailsText.isClickable = currentState.showSecondaryText
|
||||
binding.secondaryDetailsText.isFocusable = currentState.showSecondaryText
|
||||
binding.secondaryBackground.isClickable = currentState.showSecondaryText
|
||||
binding.secondaryBackground.isFocusable = currentState.showSecondaryText
|
||||
binding.primaryProgressView.isClickable = false
|
||||
binding.primaryProgressView.isFocusable = false
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryProgressView = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryProgressView = currentState.showSecondaryText,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
binding.secondaryProgressView.setStopped(false)
|
||||
setSecondaryDetailsText(currentState)
|
||||
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
|
||||
ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat()
|
||||
} else {
|
||||
ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayPendingSingleItem(currentState: TransferControlViewState) {
|
||||
binding.primaryProgressView.startClickListener = currentState.startTransferClickListener
|
||||
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton))
|
||||
binding.primaryProgressView.setStopped(false)
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryProgressView = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
binding.secondaryDetailsText.translationX = 0f
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayPendingPlayableVideo(currentState: TransferControlViewState) {
|
||||
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
|
||||
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
|
||||
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
|
||||
binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener)
|
||||
applyFocusableAndClickable(
|
||||
currentState,
|
||||
listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground, binding.playVideoButton),
|
||||
listOf(binding.primaryProgressView)
|
||||
)
|
||||
binding.secondaryProgressView.setStopped(false)
|
||||
showAllViews(
|
||||
primaryProgressView = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText,
|
||||
secondaryProgressView = currentState.showSecondaryText
|
||||
)
|
||||
setSecondaryDetailsText(currentState)
|
||||
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
|
||||
ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat()
|
||||
} else {
|
||||
ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayDownloadingGallery(currentState: TransferControlViewState) {
|
||||
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryProgressView = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
val progress = calculateProgress(currentState)
|
||||
if (progress == 0f) {
|
||||
binding.secondaryProgressView.setProgress(progress)
|
||||
} else {
|
||||
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
|
||||
binding.secondaryProgressView.setProgress(progress)
|
||||
}
|
||||
binding.secondaryDetailsText.translationX = 0f
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayDownloadingSingleItem(currentState: TransferControlViewState) {
|
||||
binding.primaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
|
||||
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton))
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryProgressView = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
val progress = calculateProgress(currentState)
|
||||
if (progress == 0f) {
|
||||
binding.primaryProgressView.setProgress(progress)
|
||||
} else {
|
||||
binding.primaryProgressView.setProgress(progress)
|
||||
}
|
||||
binding.secondaryDetailsText.translationX = 0f
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayDownloadingPlayableVideo(currentState: TransferControlViewState) {
|
||||
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
|
||||
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView, binding.playVideoButton), listOf(binding.primaryProgressView))
|
||||
showAllViews(
|
||||
primaryDetailsText = false,
|
||||
secondaryProgressView = currentState.showSecondaryText,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener)
|
||||
|
||||
val progress = calculateProgress(currentState)
|
||||
if (progress == 0f) {
|
||||
binding.secondaryProgressView.setProgress(progress)
|
||||
} else {
|
||||
binding.secondaryProgressView.setProgress(progress)
|
||||
}
|
||||
binding.secondaryDetailsText.translationX = 0f
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayUploadingSingleItem(currentState: TransferControlViewState) {
|
||||
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
|
||||
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryProgressView = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
val progress = calculateProgress(currentState)
|
||||
binding.secondaryProgressView.setProgress(progress)
|
||||
|
||||
binding.secondaryDetailsText.translationX = 0f
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayUploadingGallery(currentState: TransferControlViewState) {
|
||||
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
|
||||
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryProgressView = false,
|
||||
primaryDetailsText = false
|
||||
)
|
||||
|
||||
val progress = calculateProgress(currentState)
|
||||
binding.secondaryProgressView.setProgress(progress)
|
||||
|
||||
binding.secondaryDetailsText.translationX = 0f
|
||||
setSecondaryDetailsText(currentState)
|
||||
}
|
||||
|
||||
private fun displayRetry(currentState: TransferControlViewState, isUploading: Boolean) {
|
||||
if (currentState.startTransferClickListener == null) {
|
||||
Log.w(TAG, "No click listener set for retry!")
|
||||
}
|
||||
|
||||
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
|
||||
applyFocusableAndClickable(
|
||||
currentState,
|
||||
listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground),
|
||||
listOf(binding.primaryProgressView, binding.playVideoButton)
|
||||
)
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
primaryProgressView = false,
|
||||
primaryDetailsText = false,
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
|
||||
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
|
||||
binding.secondaryProgressView.setStopped(isUploading)
|
||||
setSecondaryDetailsText(currentState)
|
||||
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
|
||||
ViewUtil.dpToPx(-RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat()
|
||||
} else {
|
||||
ViewUtil.dpToPx(RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayChildrenAsGone() {
|
||||
children.forEach {
|
||||
if (it.visible && it.animation == null) {
|
||||
ViewUtil.fadeOut(it, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows all views by defaults, but allows individual views to be overridden to not be shown.
|
||||
*
|
||||
* @param root
|
||||
* @param playVideoButton
|
||||
* @param primaryProgressView
|
||||
* @param primaryDetailsText
|
||||
* @param secondaryProgressView
|
||||
* @param secondaryDetailsText
|
||||
*/
|
||||
private fun showAllViews(
|
||||
root: Boolean = true,
|
||||
playVideoButton: Boolean = true,
|
||||
primaryProgressView: Boolean = true,
|
||||
primaryDetailsText: Boolean = true,
|
||||
secondaryProgressView: Boolean = true,
|
||||
secondaryDetailsText: Boolean = true
|
||||
) {
|
||||
this.visible = root
|
||||
binding.playVideoButton.visible = playVideoButton
|
||||
binding.primaryProgressView.visibility = if (primaryProgressView) View.VISIBLE else View.INVISIBLE
|
||||
binding.primaryDetailsText.visible = primaryDetailsText
|
||||
binding.primaryBackground.visible = primaryProgressView || primaryDetailsText || playVideoButton
|
||||
binding.secondaryProgressView.visible = secondaryProgressView
|
||||
binding.secondaryDetailsText.visible = secondaryDetailsText
|
||||
binding.secondaryBackground.visible = secondaryProgressView || secondaryDetailsText
|
||||
val textPadding = if (secondaryProgressView) {
|
||||
context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_progressbar_to_textview_margin)
|
||||
} else {
|
||||
context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_parent_to_textview_margin)
|
||||
}
|
||||
ViewUtil.setPaddingStart(binding.secondaryDetailsText, textPadding)
|
||||
if (ViewUtil.isLtr(binding.secondaryDetailsText)) {
|
||||
(binding.secondaryDetailsText.layoutParams as MarginLayoutParams).leftMargin = textPadding
|
||||
} else {
|
||||
(binding.secondaryDetailsText.layoutParams as MarginLayoutParams).rightMargin = textPadding
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFocusableAndClickable(currentState: TransferControlViewState, activeViews: List<View>, inactiveViews: List<View>) {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val focusIntDef = if (currentState.isFocusable) View.FOCUSABLE else View.NOT_FOCUSABLE
|
||||
activeViews.forEach { it.focusable = focusIntDef }
|
||||
inactiveViews.forEach { it.focusable = View.NOT_FOCUSABLE }
|
||||
}
|
||||
activeViews.forEach { it.isClickable = currentState.isClickable }
|
||||
inactiveViews.forEach {
|
||||
it.setOnClickListener(null)
|
||||
it.isClickable = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
super.setFocusable(false)
|
||||
verboseLog("setFocusable update: $focusable")
|
||||
updateState { it.copy(isFocusable = focusable) }
|
||||
}
|
||||
|
||||
override fun setClickable(clickable: Boolean) {
|
||||
super.setClickable(false)
|
||||
verboseLog("setClickable update: $clickable")
|
||||
updateState { it.copy(isClickable = clickable) }
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
fun onEventAsync(event: PartProgressEvent) {
|
||||
val attachment = event.attachment
|
||||
updateState {
|
||||
verboseLog("onEventAsync update")
|
||||
if (!it.networkProgress.containsKey(attachment)) {
|
||||
verboseLog("onEventAsync update ignored")
|
||||
verboseLog { "Ignoring progress event for an attachment not in this view's slide set (likely a recycled view). ts=${attachment.uploadTimestamp}" }
|
||||
return@updateState it
|
||||
}
|
||||
|
||||
@@ -536,7 +119,6 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
} else if (updateEvent.completed < 0.bytes) {
|
||||
mutableMap.remove(attachment)
|
||||
}
|
||||
verboseLog("onEventAsync compression update")
|
||||
return@updateState it.copy(compressionProgress = mutableMap.toMap())
|
||||
} else {
|
||||
val mutableMap = it.networkProgress.toMutableMap()
|
||||
@@ -547,16 +129,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
} else if (updateEvent.completed < 0.bytes) {
|
||||
mutableMap.remove(attachment)
|
||||
}
|
||||
verboseLog("onEventAsync network update")
|
||||
return@updateState it.copy(networkProgress = mutableMap.toMap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSlides(slides: List<Slide>) {
|
||||
require(slides.isNotEmpty()) { "[$uuid] Must provide at least one slide." }
|
||||
require(slides.isNotEmpty()) { "Must provide at least one slide." }
|
||||
updateState { state ->
|
||||
verboseLog("State update for new slides: ${slidesAsListOfTimestamps(slides)}")
|
||||
val isNewSlideSet = !isUpdateToExistingSet(state, slides)
|
||||
val networkProgress: MutableMap<Attachment, Progress> = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap()
|
||||
if (isNewSlideSet) {
|
||||
@@ -577,25 +157,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
(it.asAttachment() as? DatabaseAttachment)?.hasData == true
|
||||
}
|
||||
|
||||
val result = state.copy(
|
||||
state.copy(
|
||||
slides = slides,
|
||||
networkProgress = networkProgress,
|
||||
compressionProgress = compressionProgress,
|
||||
playableWhileDownloading = playableWhileDownloading,
|
||||
isUpload = isUpload
|
||||
)
|
||||
verboseLog("New state calculated and being returned for new slides: ${slidesAsListOfTimestamps(slides)}\n$result")
|
||||
return@updateState result
|
||||
}
|
||||
verboseLog("End of setSlides() for ${slidesAsListOfTimestamps(slides)}")
|
||||
}
|
||||
|
||||
private fun slidesAsListOfTimestamps(slides: List<Slide>): String {
|
||||
if (!VERBOSE_DEVELOPMENT_LOGGING) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return slides.map { it.asAttachment().uploadTimestamp }.joinToString()
|
||||
}
|
||||
|
||||
private fun isUpdateToExistingSet(currentState: TransferControlViewState, slides: List<Slide>): Boolean {
|
||||
@@ -611,171 +180,52 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
}
|
||||
|
||||
fun setTransferClickListener(listener: OnClickListener) {
|
||||
verboseLog("transferClickListener update")
|
||||
updateState {
|
||||
it.copy(
|
||||
startTransferClickListener = listener
|
||||
)
|
||||
}
|
||||
updateState { it.copy(startTransferClickListener = listener) }
|
||||
}
|
||||
|
||||
fun setCancelClickListener(listener: OnClickListener) {
|
||||
verboseLog("cancelClickListener update")
|
||||
updateState {
|
||||
it.copy(
|
||||
cancelTransferClickedListener = listener
|
||||
)
|
||||
}
|
||||
updateState { it.copy(cancelTransferClickedListener = listener) }
|
||||
}
|
||||
|
||||
fun setInstantPlaybackClickListener(listener: OnClickListener) {
|
||||
verboseLog("instantPlaybackClickListener update")
|
||||
updateState {
|
||||
it.copy(
|
||||
instantPlaybackClickListener = listener
|
||||
)
|
||||
}
|
||||
updateState { it.copy(instantPlaybackClickListener = listener) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
clearAnimation()
|
||||
visibility = GONE
|
||||
updateState { TransferControlViewState() }
|
||||
}
|
||||
|
||||
fun setShowSecondaryText(showSecondaryText: Boolean) {
|
||||
verboseLog("showSecondaryText update: $showSecondaryText")
|
||||
updateState {
|
||||
it.copy(
|
||||
showSecondaryText = showSecondaryText
|
||||
)
|
||||
}
|
||||
updateState { it.copy(showSecondaryText = showSecondaryText) }
|
||||
}
|
||||
|
||||
fun setVisible(isVisible: Boolean) {
|
||||
verboseLog("showSecondaryText update: $isVisible")
|
||||
updateState {
|
||||
it.copy(
|
||||
isVisible = isVisible
|
||||
)
|
||||
}
|
||||
updateState { it.copy(isVisible = isVisible) }
|
||||
}
|
||||
|
||||
private fun isCompressing(state: TransferControlViewState): Boolean {
|
||||
val total = state.compressionProgress.sumTotal()
|
||||
return total > 0.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f
|
||||
fun setAwaitingPrimaryResponse(awaiting: Boolean) {
|
||||
updateState { it.copy(awaitingPrimaryResponse = awaiting) }
|
||||
}
|
||||
|
||||
private fun calculateProgress(state: TransferControlViewState): Float {
|
||||
val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum()
|
||||
val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(it.total) }.sum()
|
||||
val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress
|
||||
val weightedTotal = (UPLOAD_TASK_WEIGHT * state.networkProgress.size + COMPRESSION_TASK_WEIGHT * state.compressionProgress.size).toFloat()
|
||||
return weightedProgress / weightedTotal
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
super.setFocusable(false)
|
||||
updateState { it.copy(isFocusable = focusable) }
|
||||
}
|
||||
|
||||
private fun setSecondaryDetailsText(currentState: TransferControlViewState) {
|
||||
when (deriveMode(currentState)) {
|
||||
Mode.PENDING_GALLERY -> {
|
||||
binding.secondaryDetailsText.updateLayoutParams {
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
val remainingSlides = currentState.slides.filterNot { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }
|
||||
val downloadCount = remainingSlides.size
|
||||
binding.primaryDetailsText.text = context.resources.getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount)
|
||||
val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted()
|
||||
binding.secondaryDetailsText.text = size.toUnitString()
|
||||
}
|
||||
|
||||
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> {
|
||||
binding.secondaryDetailsText.updateLayoutParams {
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted()
|
||||
binding.secondaryDetailsText.text = size.toUnitString()
|
||||
}
|
||||
|
||||
Mode.PENDING_SINGLE_ITEM, Mode.PENDING_VIDEO_PLAYABLE -> {
|
||||
binding.secondaryDetailsText.updateLayoutParams {
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
val size: ByteSize = (currentState.slides.sumOf { it.asAttachment().size }).bytes
|
||||
binding.secondaryDetailsText.text = size.toUnitString()
|
||||
}
|
||||
|
||||
Mode.DOWNLOADING_GALLERY, Mode.DOWNLOADING_SINGLE_ITEM, Mode.DOWNLOADING_VIDEO_PLAYABLE, Mode.UPLOADING_GALLERY, Mode.UPLOADING_SINGLE_ITEM -> {
|
||||
if (currentState.isUpload && (currentState.networkProgress.sumCompleted() == 0.bytes || isCompressing(currentState))) {
|
||||
binding.secondaryDetailsText.updateLayoutParams {
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
binding.secondaryDetailsText.text = context.getString(R.string.TransferControlView__processing)
|
||||
} else {
|
||||
val progressMiB = currentState.networkProgress.sumCompleted().toUnitString()
|
||||
val totalMiB = currentState.networkProgress.sumTotal().toUnitString()
|
||||
val completedLabel = context.resources.getString(R.string.TransferControlView__download_progress_s_s, totalMiB, totalMiB)
|
||||
val desiredWidth = StaticLayout.getDesiredWidth(completedLabel, binding.secondaryDetailsText.paint)
|
||||
binding.secondaryDetailsText.text = context.resources.getString(R.string.TransferControlView__download_progress_s_s, progressMiB, totalMiB)
|
||||
val roundedWidth = ceil(desiredWidth.toDouble()).roundToInt() + binding.secondaryDetailsText.compoundPaddingLeft + binding.secondaryDetailsText.compoundPaddingRight
|
||||
binding.secondaryDetailsText.updateLayoutParams {
|
||||
width = roundedWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Mode.RETRY_DOWNLOADING, Mode.RETRY_UPLOADING -> {
|
||||
binding.secondaryDetailsText.text = resources.getString(R.string.NetworkFailure__retry)
|
||||
binding.secondaryDetailsText.updateLayoutParams {
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
|
||||
Mode.GONE -> Unit
|
||||
}
|
||||
override fun setClickable(clickable: Boolean) {
|
||||
super.setClickable(false)
|
||||
updateState { it.copy(isClickable = clickable) }
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an extremely chatty logging mode for local development. Each view is assigned a UUID so that you can filter by view inside a conversation.
|
||||
*/
|
||||
private fun verboseLog(message: String) {
|
||||
private inline fun verboseLog(message: () -> String) {
|
||||
if (VERBOSE_DEVELOPMENT_LOGGING) {
|
||||
Log.d(TAG, "[$uuid] $message")
|
||||
Log.d(TAG, "[$viewId] ${message()}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TransferControlView"
|
||||
private const val VERBOSE_DEVELOPMENT_LOGGING = false
|
||||
private const val UPLOAD_TASK_WEIGHT = 1
|
||||
private const val SECONDARY_TEXT_OFFSET_DP = 6
|
||||
private const val RETRY_SECONDARY_TEXT_OFFSET_DP = 6
|
||||
private const val PRIMARY_TEXT_OFFSET_DP = 4
|
||||
|
||||
/**
|
||||
* A weighting compared to [UPLOAD_TASK_WEIGHT]
|
||||
*/
|
||||
private const val COMPRESSION_TASK_WEIGHT = 3
|
||||
|
||||
@JvmStatic
|
||||
fun getTransferState(slides: List<Slide>): Int {
|
||||
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
|
||||
var allFailed = true
|
||||
for (slide in slides) {
|
||||
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
|
||||
allFailed = false
|
||||
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
|
||||
slide.transferState
|
||||
} else {
|
||||
transferState.coerceAtLeast(slide.transferState)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun containsPlayableSlides(slides: List<Slide>): Boolean {
|
||||
return slides.any { MediaUtil.isInstantVideoSupported(it) }
|
||||
}
|
||||
private fun slidesAsLogString(slides: List<Slide>): String {
|
||||
return slides.joinToString { "ts=${it.asAttachment().uploadTimestamp},xfer=${it.transferState}" }
|
||||
}
|
||||
|
||||
data class Progress(val completed: ByteSize, val total: ByteSize) {
|
||||
@@ -785,27 +235,4 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<Attachment, Progress>.sumCompleted(): ByteSize {
|
||||
return this.values.sumOf { it.completed.inWholeBytes }.bytes
|
||||
}
|
||||
|
||||
private fun Map<Attachment, Progress>.sumTotal(): ByteSize {
|
||||
return this.values.sumOf { it.total.inWholeBytes }.bytes
|
||||
}
|
||||
|
||||
enum class Mode {
|
||||
PENDING_GALLERY,
|
||||
PENDING_GALLERY_CONTAINS_PLAYABLE,
|
||||
PENDING_SINGLE_ITEM,
|
||||
PENDING_VIDEO_PLAYABLE,
|
||||
DOWNLOADING_GALLERY,
|
||||
DOWNLOADING_SINGLE_ITEM,
|
||||
DOWNLOADING_VIDEO_PLAYABLE,
|
||||
UPLOADING_GALLERY,
|
||||
UPLOADING_SINGLE_ITEM,
|
||||
RETRY_DOWNLOADING,
|
||||
RETRY_UPLOADING,
|
||||
GONE
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -21,5 +21,6 @@ data class TransferControlViewState(
|
||||
val networkProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
|
||||
val compressionProgress: Map<Attachment, TransferControlView.Progress> = HashMap(),
|
||||
val playableWhileDownloading: Boolean = false,
|
||||
val isUpload: Boolean = false
|
||||
val isUpload: Boolean = false,
|
||||
val awaitingPrimaryResponse: Boolean = false
|
||||
)
|
||||
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.transfercontrols
|
||||
|
||||
import org.signal.core.util.ByteSize
|
||||
import org.signal.core.util.bytes
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
|
||||
/**
|
||||
* Pure, Android-View-free logic for the transfer controls UI.
|
||||
*
|
||||
* [deriveRenderState] maps a [TransferControlViewState] to a [TransferControlsRenderState], which is a small, fully-resolved
|
||||
* description of what should be drawn. It carries semantic data (counts, byte sizes) rather than formatted strings so that it
|
||||
* can be unit tested on the JVM; string formatting happens in the composable.
|
||||
*/
|
||||
object TransferControls {
|
||||
|
||||
/**
|
||||
* Where the active transfer control (start button / progress indicator) is positioned.
|
||||
*
|
||||
* [CENTER] is the large, centered control used for single-item downloads.
|
||||
* [CORNER] is the small control tucked in the corner, used for galleries, playable video, all uploads, and retries.
|
||||
*/
|
||||
enum class Placement {
|
||||
CENTER,
|
||||
CORNER
|
||||
}
|
||||
|
||||
sealed interface ProgressLabel {
|
||||
/** Attachment processing taking place, like transcoding */
|
||||
data object Processing : ProgressLabel
|
||||
|
||||
/** Uploading/downloading progress */
|
||||
data class Bytes(val completed: ByteSize, val total: ByteSize) : ProgressLabel
|
||||
}
|
||||
|
||||
fun deriveRenderState(state: TransferControlViewState): TransferControlsRenderState {
|
||||
if (state.slides.isEmpty()) {
|
||||
return TransferControlsRenderState.Gone
|
||||
}
|
||||
|
||||
if (state.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
|
||||
return TransferControlsRenderState.Gone
|
||||
}
|
||||
|
||||
if (!state.isVisible) {
|
||||
return TransferControlsRenderState.Gone
|
||||
}
|
||||
|
||||
if (state.awaitingPrimaryResponse) {
|
||||
return TransferControlsRenderState.InProgress(
|
||||
isUpload = false,
|
||||
placement = if (state.slides.size == 1) Placement.CENTER else Placement.CORNER,
|
||||
progress = null,
|
||||
showPlayButton = false,
|
||||
cancelable = false,
|
||||
label = null
|
||||
)
|
||||
}
|
||||
|
||||
return when (deriveMode(state)) {
|
||||
Mode.PENDING_GALLERY -> TransferControlsRenderState.Pending(
|
||||
isUpload = state.isUpload,
|
||||
placement = Placement.CENTER,
|
||||
showPlayButton = false,
|
||||
itemCount = state.slides.count { it.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE },
|
||||
sizeBytes = if (state.showSecondaryText) state.networkProgress.sumTotal() - state.networkProgress.sumCompleted() else null
|
||||
)
|
||||
|
||||
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> TransferControlsRenderState.Pending(
|
||||
isUpload = state.isUpload,
|
||||
placement = Placement.CORNER,
|
||||
showPlayButton = false,
|
||||
sizeBytes = if (state.showSecondaryText) state.networkProgress.sumTotal() - state.networkProgress.sumCompleted() else null
|
||||
)
|
||||
|
||||
Mode.PENDING_SINGLE_ITEM -> TransferControlsRenderState.Pending(
|
||||
isUpload = state.isUpload,
|
||||
placement = Placement.CENTER,
|
||||
showPlayButton = false,
|
||||
sizeBytes = if (state.showSecondaryText) state.slides.sumOf { it.asAttachment().size }.bytes else null
|
||||
)
|
||||
|
||||
Mode.PENDING_VIDEO_PLAYABLE -> TransferControlsRenderState.Pending(
|
||||
isUpload = state.isUpload,
|
||||
placement = Placement.CORNER,
|
||||
showPlayButton = true,
|
||||
sizeBytes = if (state.showSecondaryText) state.slides.sumOf { it.asAttachment().size }.bytes else null
|
||||
)
|
||||
|
||||
Mode.DOWNLOADING_GALLERY -> TransferControlsRenderState.InProgress(
|
||||
isUpload = false,
|
||||
placement = Placement.CORNER,
|
||||
progress = calculateProgress(state),
|
||||
showPlayButton = false,
|
||||
cancelable = calculateProgress(state) != 0f,
|
||||
label = progressLabel(state)
|
||||
)
|
||||
|
||||
Mode.DOWNLOADING_SINGLE_ITEM -> TransferControlsRenderState.InProgress(
|
||||
isUpload = false,
|
||||
placement = Placement.CENTER,
|
||||
progress = calculateProgress(state),
|
||||
showPlayButton = false,
|
||||
cancelable = true,
|
||||
label = progressLabel(state)
|
||||
)
|
||||
|
||||
Mode.DOWNLOADING_VIDEO_PLAYABLE -> TransferControlsRenderState.InProgress(
|
||||
isUpload = false,
|
||||
placement = Placement.CORNER,
|
||||
progress = calculateProgress(state),
|
||||
showPlayButton = true,
|
||||
cancelable = true,
|
||||
label = progressLabel(state)
|
||||
)
|
||||
|
||||
Mode.UPLOADING_SINGLE_ITEM -> TransferControlsRenderState.InProgress(
|
||||
isUpload = true,
|
||||
placement = Placement.CORNER,
|
||||
progress = calculateProgress(state),
|
||||
showPlayButton = false,
|
||||
cancelable = true,
|
||||
label = progressLabel(state)
|
||||
)
|
||||
|
||||
Mode.UPLOADING_GALLERY -> TransferControlsRenderState.InProgress(
|
||||
isUpload = true,
|
||||
placement = Placement.CORNER,
|
||||
progress = calculateProgress(state),
|
||||
showPlayButton = false,
|
||||
cancelable = true,
|
||||
// Note: the legacy view always showed this label for uploading galleries, regardless of showSecondaryText.
|
||||
label = progressLabel(state)
|
||||
)
|
||||
|
||||
Mode.RETRY_DOWNLOADING -> TransferControlsRenderState.Retry(isUpload = false)
|
||||
Mode.RETRY_UPLOADING -> TransferControlsRenderState.Retry(isUpload = true)
|
||||
Mode.GONE -> TransferControlsRenderState.Gone
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressLabel(state: TransferControlViewState): ProgressLabel {
|
||||
return if (state.isUpload && (state.networkProgress.sumCompleted() == 0L.bytes || isCompressing(state))) {
|
||||
ProgressLabel.Processing
|
||||
} else if (state.isUpload) {
|
||||
ProgressLabel.Bytes(state.networkProgress.sumCompleted(), state.networkProgress.sumTotal())
|
||||
} else {
|
||||
val total = state.slides.sumOf { it.fileSize }.bytes
|
||||
val completed = state.networkProgress.sumCompleted().let { if (it > total) total else it }
|
||||
ProgressLabel.Bytes(completed, total)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCompressing(state: TransferControlViewState): Boolean {
|
||||
val total = state.compressionProgress.sumTotal()
|
||||
return total > 0L.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f
|
||||
}
|
||||
|
||||
private fun calculateProgress(state: TransferControlViewState): Float {
|
||||
val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum()
|
||||
val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(it.total) }.sum()
|
||||
val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress
|
||||
val weightedTotal = (UPLOAD_TASK_WEIGHT * state.networkProgress.size + COMPRESSION_TASK_WEIGHT * state.compressionProgress.size).toFloat()
|
||||
return weightedProgress / weightedTotal
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal, view-free mirror of the legacy state machine. Kept verbatim from the original view to preserve behavior; the
|
||||
* resulting [Mode] is mapped to a [TransferControlsRenderState] by [deriveRenderState].
|
||||
*/
|
||||
private fun deriveMode(state: TransferControlViewState): Mode {
|
||||
if (state.slides.isEmpty()) {
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
if (state.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
if (state.isVisible) {
|
||||
if (state.slides.size == 1) {
|
||||
val slide = state.slides.first()
|
||||
if (slide.hasVideo()) {
|
||||
if (state.isUpload) {
|
||||
return when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> Mode.UPLOADING_SINGLE_ITEM
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING -> Mode.PENDING_SINGLE_ITEM
|
||||
else -> Mode.RETRY_UPLOADING
|
||||
}
|
||||
} else {
|
||||
return when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
if (state.playableWhileDownloading) Mode.DOWNLOADING_VIDEO_PLAYABLE else Mode.DOWNLOADING_SINGLE_ITEM
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_DOWNLOADING
|
||||
else -> {
|
||||
if (state.playableWhileDownloading) Mode.PENDING_VIDEO_PLAYABLE else Mode.PENDING_SINGLE_ITEM
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return if (state.isUpload) {
|
||||
when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_UPLOADING
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING -> Mode.PENDING_SINGLE_ITEM
|
||||
else -> Mode.UPLOADING_SINGLE_ITEM
|
||||
}
|
||||
} else {
|
||||
when (slide.transferState) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> Mode.DOWNLOADING_SINGLE_ITEM
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> Mode.RETRY_DOWNLOADING
|
||||
else -> Mode.PENDING_SINGLE_ITEM
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (getTransferState(state.slides)) {
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
return if (state.isUpload) Mode.UPLOADING_GALLERY else Mode.DOWNLOADING_GALLERY
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
|
||||
return if (containsPlayableSlides(state.slides)) Mode.PENDING_GALLERY_CONTAINS_PLAYABLE else Mode.PENDING_GALLERY
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
|
||||
return if (state.isUpload) Mode.RETRY_UPLOADING else Mode.RETRY_DOWNLOADING
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_DONE -> return Mode.GONE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
return Mode.GONE
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getTransferState(slides: List<Slide>): Int {
|
||||
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
|
||||
var allFailed = true
|
||||
for (slide in slides) {
|
||||
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
|
||||
allFailed = false
|
||||
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
|
||||
slide.transferState
|
||||
} else {
|
||||
transferState.coerceAtLeast(slide.transferState)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun containsPlayableSlides(slides: List<Slide>): Boolean {
|
||||
return slides.any { MediaUtil.isInstantVideoSupported(it) }
|
||||
}
|
||||
|
||||
private fun Map<Attachment, TransferControlView.Progress>.sumCompleted(): ByteSize {
|
||||
return this.values.sumOf { it.completed.inWholeBytes }.bytes
|
||||
}
|
||||
|
||||
private fun Map<Attachment, TransferControlView.Progress>.sumTotal(): ByteSize {
|
||||
return this.values.sumOf { it.total.inWholeBytes }.bytes
|
||||
}
|
||||
|
||||
private const val UPLOAD_TASK_WEIGHT = 1
|
||||
|
||||
/**
|
||||
* A weighting compared to [UPLOAD_TASK_WEIGHT]
|
||||
*/
|
||||
private const val COMPRESSION_TASK_WEIGHT = 3
|
||||
|
||||
private enum class Mode {
|
||||
PENDING_GALLERY,
|
||||
PENDING_GALLERY_CONTAINS_PLAYABLE,
|
||||
PENDING_SINGLE_ITEM,
|
||||
PENDING_VIDEO_PLAYABLE,
|
||||
DOWNLOADING_GALLERY,
|
||||
DOWNLOADING_SINGLE_ITEM,
|
||||
DOWNLOADING_VIDEO_PLAYABLE,
|
||||
UPLOADING_GALLERY,
|
||||
UPLOADING_SINGLE_ITEM,
|
||||
RETRY_DOWNLOADING,
|
||||
RETRY_UPLOADING,
|
||||
GONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-resolved description of what the transfer controls should display. Produced by [TransferControls.deriveRenderState].
|
||||
*/
|
||||
sealed interface TransferControlsRenderState {
|
||||
data object Gone : TransferControlsRenderState
|
||||
|
||||
data class Pending(
|
||||
val isUpload: Boolean,
|
||||
val placement: TransferControls.Placement,
|
||||
val showPlayButton: Boolean,
|
||||
val itemCount: Int? = null,
|
||||
val sizeBytes: ByteSize? = null
|
||||
) : TransferControlsRenderState
|
||||
|
||||
data class InProgress(
|
||||
val isUpload: Boolean,
|
||||
val placement: TransferControls.Placement,
|
||||
val progress: Float?,
|
||||
val showPlayButton: Boolean,
|
||||
val cancelable: Boolean,
|
||||
val label: TransferControls.ProgressLabel?
|
||||
) : TransferControlsRenderState
|
||||
|
||||
data class Retry(
|
||||
val isUpload: Boolean
|
||||
) : TransferControlsRenderState
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.transfercontrols
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.clickableContainer
|
||||
import org.signal.core.util.bytes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
private val CENTER_CONTROL_SIZE = 44.dp
|
||||
private val NO_FONT_PADDING = PlatformTextStyle(includeFontPadding = false)
|
||||
|
||||
/**
|
||||
* Compose rendering of the attachment transfer controls (start/cancel/progress) that overlay a media thumbnail.
|
||||
*
|
||||
* This renders a [TransferControlsRenderState] produced by [TransferControls.deriveRenderState]. All state derivation lives in
|
||||
* [TransferControls]; this function is purely presentational so the various visual states can be previewed and tested directly.
|
||||
*/
|
||||
@Composable
|
||||
fun TransferControls(
|
||||
state: TransferControlsRenderState,
|
||||
modifier: Modifier = Modifier,
|
||||
onStartClick: () -> Unit = {},
|
||||
onCancelClick: () -> Unit = {},
|
||||
onPlayClick: () -> Unit = {}
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (state) {
|
||||
is TransferControlsRenderState.Gone -> Unit
|
||||
|
||||
is TransferControlsRenderState.Pending -> Content(
|
||||
control = TransferProgressState.Ready(
|
||||
icon = arrowIcon(state.isUpload),
|
||||
startButtonContentDesc = startContentDescription(state.isUpload),
|
||||
startButtonOnClickLabel = startContentDescription(state.isUpload),
|
||||
onStartClick = onStartClick
|
||||
),
|
||||
placement = state.placement,
|
||||
showPlayButton = state.showPlayButton,
|
||||
centerLabel = state.itemCount?.let { pluralStringResource(R.plurals.TransferControlView_n_items, it, it) },
|
||||
cornerText = state.sizeBytes?.toUnitString(),
|
||||
onPlayClick = onPlayClick
|
||||
)
|
||||
|
||||
is TransferControlsRenderState.Retry -> Content(
|
||||
control = TransferProgressState.Ready(
|
||||
icon = arrowIcon(state.isUpload),
|
||||
startButtonContentDesc = startContentDescription(state.isUpload),
|
||||
startButtonOnClickLabel = startContentDescription(state.isUpload),
|
||||
onStartClick = onStartClick
|
||||
),
|
||||
placement = TransferControls.Placement.CORNER,
|
||||
showPlayButton = false,
|
||||
centerLabel = null,
|
||||
cornerText = stringResource(R.string.NetworkFailure__retry),
|
||||
onPlayClick = onPlayClick
|
||||
)
|
||||
|
||||
is TransferControlsRenderState.InProgress -> {
|
||||
val cancelLabel = stringResource(android.R.string.cancel)
|
||||
val label = state.label
|
||||
val progressFormat = stringResource(R.string.TransferControlView__download_progress_s_s)
|
||||
val cornerTextReserveWidthFor = (label as? TransferControls.ProgressLabel.Bytes)?.let { byteLabel ->
|
||||
val unit = byteLabel.total.getLargestNonZeroSize()
|
||||
val widestCompleted = byteLabel.total.toUnitString(unit, padDecimals = true, withUnit = false)
|
||||
val totalText = byteLabel.total.toUnitString(unit)
|
||||
progressFormat.format(widestCompleted, totalText)
|
||||
}
|
||||
|
||||
Content(
|
||||
control = TransferProgressState.InProgress(
|
||||
progress = state.progress,
|
||||
cancelAction = if (state.cancelable) {
|
||||
TransferProgressState.InProgress.CancelAction(
|
||||
contentDesc = cancelLabel,
|
||||
onClickLabel = cancelLabel,
|
||||
onClick = onCancelClick
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
),
|
||||
placement = state.placement,
|
||||
showPlayButton = state.showPlayButton,
|
||||
centerLabel = null,
|
||||
cornerText = label?.let { progressLabelText(it) },
|
||||
cornerTextReserveWidthFor = cornerTextReserveWidthFor,
|
||||
onPlayClick = onPlayClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.Content(
|
||||
control: TransferProgressState,
|
||||
placement: TransferControls.Placement,
|
||||
showPlayButton: Boolean,
|
||||
centerLabel: String?,
|
||||
cornerText: String?,
|
||||
onPlayClick: () -> Unit,
|
||||
cornerTextReserveWidthFor: String? = null
|
||||
) {
|
||||
val controlInCenter = placement == TransferControls.Placement.CENTER
|
||||
val controlInCorner = placement == TransferControls.Placement.CORNER
|
||||
|
||||
if (controlInCenter || showPlayButton || centerLabel != null) {
|
||||
Pill(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
cornerRadius = 24.dp
|
||||
) {
|
||||
if (controlInCenter) {
|
||||
OnMediaIndicator(control, CENTER_CONTROL_SIZE)
|
||||
}
|
||||
|
||||
if (showPlayButton) {
|
||||
PlayButton(onPlayClick)
|
||||
}
|
||||
|
||||
if (centerLabel != null) {
|
||||
Text(
|
||||
text = centerLabel,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(platformStyle = NO_FONT_PADDING),
|
||||
color = colorResource(CoreUiR.color.signal_colorOnCustom),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(end = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (controlInCorner || cornerText != null) {
|
||||
Pill(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(4.dp),
|
||||
cornerRadius = 16.dp
|
||||
) {
|
||||
if (controlInCorner) {
|
||||
OnMediaIndicator(control, 32.dp)
|
||||
}
|
||||
|
||||
if (cornerText != null) {
|
||||
if (!controlInCorner) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
CornerText(
|
||||
text = cornerText,
|
||||
reserveWidthFor = cornerTextReserveWidthFor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Pill(
|
||||
modifier: Modifier = Modifier,
|
||||
cornerRadius: Dp,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(cornerRadius))
|
||||
.background(colorResource(CoreUiR.color.signal_colorTransparentInverse4)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps [TransferProgressIndicator] in a color scheme override so it adopts the on-media ("OnCustom") palette rather than the
|
||||
* default surface palette, matching the legacy view's appearance over thumbnails.
|
||||
*/
|
||||
@Composable
|
||||
private fun OnMediaIndicator(state: TransferProgressState, size: Dp) {
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
onSurface = colorResource(CoreUiR.color.signal_colorOnCustom),
|
||||
surfaceContainerHighest = colorResource(CoreUiR.color.signal_colorTransparent2)
|
||||
)
|
||||
) {
|
||||
TransferProgressIndicator(state = state, size = size)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayButton(onPlayClick: () -> Unit) {
|
||||
val description = stringResource(R.string.ThumbnailView_Play_video_description)
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.triangle_right),
|
||||
contentDescription = description,
|
||||
tint = colorResource(CoreUiR.color.signal_colorOnCustom),
|
||||
modifier = Modifier
|
||||
.size(CENTER_CONTROL_SIZE)
|
||||
.clickableContainer(
|
||||
contentDescription = description,
|
||||
onClickLabel = description,
|
||||
onClick = onPlayClick
|
||||
)
|
||||
.padding(10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CornerText(
|
||||
text: String,
|
||||
reserveWidthFor: String?
|
||||
) {
|
||||
val reserving = reserveWidthFor != null
|
||||
val style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Light,
|
||||
platformStyle = NO_FONT_PADDING
|
||||
)
|
||||
val effectiveStyle = if (reserving) style.copy(fontFeatureSettings = "tnum") else style
|
||||
|
||||
val widthModifier = if (reserving) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val reservedWidth = remember(reserveWidthFor, effectiveStyle, density) {
|
||||
with(density) { measurer.measure(reserveWidthFor, effectiveStyle).size.width.toDp() }
|
||||
}
|
||||
Modifier.width(reservedWidth)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = effectiveStyle,
|
||||
color = colorResource(CoreUiR.color.signal_colorOnCustom),
|
||||
maxLines = 1,
|
||||
textAlign = if (reserving) TextAlign.End else null,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp, top = 8.dp, bottom = 8.dp)
|
||||
.then(widthModifier)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun arrowIcon(isUpload: Boolean): ImageVector {
|
||||
return ImageVector.vectorResource(if (isUpload) R.drawable.symbol_arrow_up_24 else R.drawable.symbol_arrow_down_24)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun startContentDescription(isUpload: Boolean): String {
|
||||
return stringResource(if (isUpload) R.string.TransferControlView__upload else R.string.TransferControlView__download)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun progressLabelText(label: TransferControls.ProgressLabel): String {
|
||||
return when (label) {
|
||||
is TransferControls.ProgressLabel.Processing -> stringResource(R.string.TransferControlView__processing)
|
||||
is TransferControls.ProgressLabel.Bytes -> {
|
||||
val unit = label.total.getLargestNonZeroSize()
|
||||
stringResource(
|
||||
R.string.TransferControlView__download_progress_s_s,
|
||||
label.completed.toUnitString(unit, padDecimals = true, withUnit = false),
|
||||
label.total.toUnitString(unit)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferControlsPendingSinglePreview() {
|
||||
Previews.Preview {
|
||||
PreviewSurface {
|
||||
TransferControls(
|
||||
state = TransferControlsRenderState.Pending(
|
||||
isUpload = false,
|
||||
placement = TransferControls.Placement.CENTER,
|
||||
showPlayButton = false,
|
||||
sizeBytes = (2 * 1024 * 1024L).bytes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferControlsPendingGalleryPreview() {
|
||||
Previews.Preview {
|
||||
PreviewSurface {
|
||||
TransferControls(
|
||||
state = TransferControlsRenderState.Pending(
|
||||
isUpload = false,
|
||||
placement = TransferControls.Placement.CENTER,
|
||||
showPlayButton = false,
|
||||
itemCount = 3,
|
||||
sizeBytes = (6 * 1024 * 1024L).bytes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferControlsPendingPlayableVideoPreview() {
|
||||
Previews.Preview {
|
||||
PreviewSurface {
|
||||
TransferControls(
|
||||
state = TransferControlsRenderState.Pending(
|
||||
isUpload = false,
|
||||
placement = TransferControls.Placement.CORNER,
|
||||
showPlayButton = true,
|
||||
sizeBytes = (12 * 1024 * 1024L).bytes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferControlsDownloadingSinglePreview() {
|
||||
Previews.Preview {
|
||||
PreviewSurface {
|
||||
TransferControls(
|
||||
state = TransferControlsRenderState.InProgress(
|
||||
isUpload = false,
|
||||
placement = TransferControls.Placement.CORNER,
|
||||
progress = 0.45f,
|
||||
showPlayButton = false,
|
||||
cancelable = true,
|
||||
label = TransferControls.ProgressLabel.Bytes((1024 * 1024L).bytes, (2 * 1024 * 1024L).bytes)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferControlsAwaitingPrimaryPreview() {
|
||||
Previews.Preview {
|
||||
PreviewSurface {
|
||||
TransferControls(
|
||||
state = TransferControlsRenderState.InProgress(
|
||||
isUpload = false,
|
||||
placement = TransferControls.Placement.CENTER,
|
||||
progress = null,
|
||||
showPlayButton = false,
|
||||
cancelable = false,
|
||||
label = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferControlsRetryPreview() {
|
||||
Previews.Preview {
|
||||
PreviewSurface {
|
||||
TransferControls(
|
||||
state = TransferControlsRenderState.Retry(isUpload = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewSurface(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(150.dp)
|
||||
.background(colorResource(CoreUiR.color.signal_colorTransparent2))
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
+141
-11
@@ -13,20 +13,32 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.clickableContainer
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* A button that can be used to start, cancel, show progress, and show completion of a data transfer.
|
||||
@@ -34,10 +46,19 @@ import org.signal.core.ui.compose.clickableContainer
|
||||
@Composable
|
||||
fun TransferProgressIndicator(
|
||||
state: TransferProgressState,
|
||||
modifier: Modifier = Modifier.size(48.dp)
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 48.dp
|
||||
) {
|
||||
// Internal paddings are tuned for a 48dp control; scale them with [size] so the icon/ring proportions are preserved at
|
||||
// other sizes. At 48dp this is a no-op, so existing callers are unaffected.
|
||||
val scale = size / 48.dp
|
||||
val sizedModifier = modifier.size(size)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
// Key on the state type, not the value, so that progress updates within InProgress recompose in place instead of
|
||||
// re-triggering the enter/exit transition on every tick (which would prevent the determinate fill from ever settling).
|
||||
contentKey = { it::class },
|
||||
transitionSpec = {
|
||||
val startDelay = 200
|
||||
val enterTransition = fadeIn(tween(delayMillis = startDelay, durationMillis = 500)) + scaleIn(tween(delayMillis = startDelay, durationMillis = 400))
|
||||
@@ -48,9 +69,9 @@ fun TransferProgressIndicator(
|
||||
}
|
||||
) { targetState ->
|
||||
when (targetState) {
|
||||
is TransferProgressState.Ready -> StartTransferButton(targetState, modifier)
|
||||
is TransferProgressState.InProgress -> ProgressIndicator(targetState, modifier)
|
||||
is TransferProgressState.Complete -> CompleteIcon(targetState, modifier)
|
||||
is TransferProgressState.Ready -> StartTransferButton(targetState, sizedModifier, scale)
|
||||
is TransferProgressState.InProgress -> ProgressIndicator(targetState, sizedModifier, scale)
|
||||
is TransferProgressState.Complete -> CompleteIcon(targetState, sizedModifier, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +79,8 @@ fun TransferProgressIndicator(
|
||||
@Composable
|
||||
private fun StartTransferButton(
|
||||
state: TransferProgressState.Ready,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
scale: Float = 1f
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -74,7 +96,7 @@ private fun StartTransferButton(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(12.dp)
|
||||
.padding(12.dp * scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,7 +104,8 @@ private fun StartTransferButton(
|
||||
@Composable
|
||||
private fun ProgressIndicator(
|
||||
state: TransferProgressState.InProgress,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
scale: Float = 1f
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -97,7 +120,7 @@ private fun ProgressIndicator(
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(10.dp)
|
||||
.padding(10.dp * scale)
|
||||
) {
|
||||
state.icon?.let { icon ->
|
||||
Icon(
|
||||
@@ -106,7 +129,7 @@ private fun ProgressIndicator(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(6.dp)
|
||||
.padding(6.dp * scale)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,19 +164,32 @@ private fun ProgressIndicator(
|
||||
modifier = indicatorModifier
|
||||
)
|
||||
}
|
||||
|
||||
// When cancelable, draw the filled "stop" square in the center of the ring (matches the legacy view's
|
||||
// IN_PROGRESS_CANCELABLE state). Sized as a fraction of the control so it scales with center/corner placements.
|
||||
if (state.cancelAction != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxSize(0.3f)
|
||||
.clip(RoundedCornerShape(percent = 15))
|
||||
.background(MaterialTheme.colorScheme.onSurface)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompleteIcon(
|
||||
state: TransferProgressState.Complete,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
scale: Float = 1f
|
||||
) {
|
||||
Icon(
|
||||
imageVector = state.icon,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = state.iconContentDesc,
|
||||
modifier = modifier.padding(12.dp)
|
||||
modifier = modifier.padding(12.dp * scale)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,3 +219,97 @@ sealed interface TransferProgressState {
|
||||
val iconContentDesc: String
|
||||
) : TransferProgressState
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferProgressIndicatorReadyPreview() {
|
||||
Previews.Preview {
|
||||
PreviewBackdrop {
|
||||
TransferProgressIndicator(
|
||||
state = TransferProgressState.Ready(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24),
|
||||
startButtonContentDesc = "",
|
||||
startButtonOnClickLabel = "",
|
||||
onStartClick = {}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferProgressIndicatorIndeterminatePreview() {
|
||||
Previews.Preview {
|
||||
PreviewBackdrop {
|
||||
TransferProgressIndicator(
|
||||
state = TransferProgressState.InProgress(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24),
|
||||
progress = null,
|
||||
cancelAction = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferProgressIndicatorDeterminatePreview() {
|
||||
Previews.Preview {
|
||||
PreviewBackdrop {
|
||||
TransferProgressIndicator(
|
||||
state = TransferProgressState.InProgress(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_arrow_down_24),
|
||||
progress = 0.4f,
|
||||
cancelAction = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferProgressIndicatorCancelablePreview() {
|
||||
Previews.Preview {
|
||||
PreviewBackdrop {
|
||||
TransferProgressIndicator(
|
||||
state = TransferProgressState.InProgress(
|
||||
progress = 0.4f,
|
||||
cancelAction = TransferProgressState.InProgress.CancelAction(
|
||||
contentDesc = "",
|
||||
onClickLabel = "",
|
||||
onClick = {}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun TransferProgressIndicatorCompletePreview() {
|
||||
Previews.Preview {
|
||||
PreviewBackdrop {
|
||||
TransferProgressIndicator(
|
||||
state = TransferProgressState.Complete(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_check_white_24),
|
||||
iconContentDesc = ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewBackdrop(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.background(colorResource(CoreUiR.color.signal_colorTransparent2))
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.transfercontrols
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
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
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.math.roundToInt
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* 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,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : View(context, attrs, defStyleAttr, defStyleRes) {
|
||||
companion object {
|
||||
const val TAG = "TransferProgressView"
|
||||
private const val PROGRESS_ARC_STROKE_WIDTH_DP = 2f
|
||||
private const val ICON_SIZE_DP = 24f
|
||||
private const val STOP_CORNER_RADIUS_DP = 4f
|
||||
private const val PROGRESS_BAR_INSET_DP = 2
|
||||
}
|
||||
|
||||
private val iconColor: Int
|
||||
private val progressColor: Int
|
||||
private val trackColor: Int
|
||||
private val stopIconPaint: Paint
|
||||
private val progressPaint: Paint
|
||||
private val trackPaint: Paint
|
||||
private val progressArcStrokeWidth: Float
|
||||
private val iconSize: Float
|
||||
private val stopIconSize: Float
|
||||
private val stopIconCornerRadius: Float
|
||||
|
||||
private val progressRect = RectF()
|
||||
private val stopIconRect = RectF()
|
||||
private val downloadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_down_24)
|
||||
private val uploadDrawable = ContextCompat.getDrawable(context, R.drawable.symbol_arrow_up_24)
|
||||
|
||||
private var progressPercent = 0f
|
||||
private var currentState = State.UNINITIALIZED
|
||||
|
||||
var startClickListener: OnClickListener? = null
|
||||
var cancelClickListener: OnClickListener? = null
|
||||
|
||||
init {
|
||||
val displayDensity = Resources.getSystem().displayMetrics.density
|
||||
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TransferProgressView, 0, 0)
|
||||
val signalCustomColor = ContextCompat.getColor(context, CoreUiR.color.signal_colorOnCustom)
|
||||
val signalTransparent2 = ContextCompat.getColor(context, CoreUiR.color.signal_colorTransparent2)
|
||||
|
||||
iconColor = typedArray.getColor(R.styleable.TransferProgressView_transferIconColor, signalCustomColor)
|
||||
progressColor = typedArray.getColor(R.styleable.TransferProgressView_progressColor, signalCustomColor)
|
||||
trackColor = typedArray.getColor(R.styleable.TransferProgressView_trackColor, signalTransparent2)
|
||||
progressArcStrokeWidth = typedArray.getDimension(R.styleable.TransferProgressView_progressArcWidth, PROGRESS_ARC_STROKE_WIDTH_DP * displayDensity)
|
||||
iconSize = typedArray.getDimension(R.styleable.TransferProgressView_iconSize, ICON_SIZE_DP * displayDensity)
|
||||
stopIconSize = typedArray.getDimension(R.styleable.TransferProgressView_stopIconSize, ICON_SIZE_DP * displayDensity)
|
||||
stopIconCornerRadius = typedArray.getDimension(R.styleable.TransferProgressView_stopIconCornerRadius, STOP_CORNER_RADIUS_DP * displayDensity)
|
||||
|
||||
typedArray.recycle()
|
||||
|
||||
progressPaint = progressPaint(progressColor)
|
||||
stopIconPaint = stopIconPaint(iconColor)
|
||||
trackPaint = trackPaint(trackColor)
|
||||
|
||||
val filter = PorterDuffColorFilter(iconColor, PorterDuff.Mode.SRC_ATOP)
|
||||
downloadDrawable?.colorFilter = filter
|
||||
uploadDrawable?.colorFilter = filter
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
when (currentState) {
|
||||
State.IN_PROGRESS_CANCELABLE -> drawProgress(canvas, progressPercent, true)
|
||||
State.IN_PROGRESS_NON_CANCELABLE -> drawProgress(canvas, progressPercent, false)
|
||||
State.READY_TO_UPLOAD -> sizeAndDrawDrawable(canvas, uploadDrawable)
|
||||
State.READY_TO_DOWNLOAD -> sizeAndDrawDrawable(canvas, downloadDrawable)
|
||||
State.UNINITIALIZED -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun setProgress(progress: Float) {
|
||||
currentState = State.IN_PROGRESS_CANCELABLE
|
||||
if (cancelClickListener == null) {
|
||||
Log.i(TAG, "Illegal click listener attached.")
|
||||
} else {
|
||||
setOnClickListener(cancelClickListener)
|
||||
}
|
||||
progressPercent = progress
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setStopped(isUpload: Boolean) {
|
||||
val newState = if (isUpload) State.READY_TO_UPLOAD else State.READY_TO_DOWNLOAD
|
||||
currentState = newState
|
||||
if (startClickListener == null) {
|
||||
Log.i(TAG, "Illegal click listener attached.")
|
||||
} else {
|
||||
setOnClickListener(startClickListener)
|
||||
}
|
||||
progressPercent = 0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun drawProgress(canvas: Canvas, progressPercent: Float, showStopIcon: Boolean) {
|
||||
if (showStopIcon) {
|
||||
stopIconRect.set(0f, 0f, stopIconSize, stopIconSize)
|
||||
|
||||
canvas.withTranslation(width / 2 - (stopIconSize / 2), height / 2 - (stopIconSize / 2)) {
|
||||
drawRoundRect(stopIconRect, stopIconCornerRadius, stopIconCornerRadius, stopIconPaint)
|
||||
}
|
||||
}
|
||||
|
||||
val trackWidthScaled = progressArcStrokeWidth
|
||||
val inset: Float = PROGRESS_BAR_INSET_DP * Resources.getSystem().displayMetrics.density
|
||||
progressRect.left = trackWidthScaled + inset
|
||||
progressRect.top = trackWidthScaled + inset
|
||||
progressRect.right = (width - trackWidthScaled) - inset
|
||||
progressRect.bottom = (height - trackWidthScaled) - inset
|
||||
|
||||
canvas.drawArc(progressRect, 0f, 360f, false, trackPaint)
|
||||
canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint)
|
||||
}
|
||||
|
||||
private fun stopIconPaint(paintColor: Int): Paint {
|
||||
val stopIconPaint = Paint()
|
||||
stopIconPaint.color = paintColor
|
||||
stopIconPaint.isAntiAlias = true
|
||||
stopIconPaint.style = Paint.Style.FILL
|
||||
return stopIconPaint
|
||||
}
|
||||
|
||||
private fun trackPaint(trackColor: Int): Paint {
|
||||
val trackPaint = Paint()
|
||||
trackPaint.color = trackColor
|
||||
trackPaint.isAntiAlias = true
|
||||
trackPaint.style = Paint.Style.STROKE
|
||||
trackPaint.strokeWidth = progressArcStrokeWidth
|
||||
return trackPaint
|
||||
}
|
||||
|
||||
private fun progressPaint(progressColor: Int): Paint {
|
||||
val progressPaint = Paint()
|
||||
progressPaint.color = progressColor
|
||||
progressPaint.isAntiAlias = true
|
||||
progressPaint.style = Paint.Style.STROKE
|
||||
progressPaint.strokeWidth = progressArcStrokeWidth
|
||||
return progressPaint
|
||||
}
|
||||
|
||||
private fun sizeAndDrawDrawable(canvas: Canvas, drawable: Drawable?) {
|
||||
if (drawable == null) {
|
||||
Log.w(TAG, "Could not load icon for $currentState")
|
||||
return
|
||||
}
|
||||
|
||||
val centerX = width / 2f
|
||||
val centerY = height / 2f
|
||||
|
||||
// 0, 0 is the top left corner
|
||||
// width, height is the bottom right
|
||||
val halfIconSize = (iconSize / 2f)
|
||||
val left = (centerX - halfIconSize).roundToInt().coerceAtLeast(0)
|
||||
val top = (centerY - halfIconSize).roundToInt().coerceAtLeast(0)
|
||||
val right = (centerX + halfIconSize).roundToInt().coerceAtMost(width)
|
||||
val bottom = (centerY + halfIconSize).roundToInt().coerceAtMost(height)
|
||||
|
||||
drawable.setBounds(left, top, right, bottom)
|
||||
drawable.draw(canvas)
|
||||
}
|
||||
|
||||
private enum class State {
|
||||
IN_PROGRESS_CANCELABLE,
|
||||
IN_PROGRESS_NON_CANCELABLE,
|
||||
READY_TO_UPLOAD,
|
||||
READY_TO_DOWNLOAD,
|
||||
UNINITIALIZED
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import org.signal.archive.proto.GroupChangeChatUpdate;
|
||||
import org.signal.archive.proto.GroupCreationUpdate;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiProvider;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
|
||||
import org.thoughtcrime.securesms.database.CollapsedState;
|
||||
import org.thoughtcrime.securesms.database.MessageTypes;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
@@ -963,7 +963,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
if (slides.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return TransferControlView.getTransferState(slides) == expectedTransferState;
|
||||
return TransferControls.getTransferState(slides) == expectedTransferState;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
-1
@@ -9,7 +9,6 @@ import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
|
||||
-1
@@ -24,7 +24,6 @@ import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItem;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.components.FromTextView;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
|
||||
Reference in New Issue
Block a user