Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 0b30f918 authored by Saalim Quadri's avatar Saalim Quadri
Browse files

feat: Properly handle button pending/install states and use progressFill

- Squash of following commits:
  012e2fb5,
  f545d61b,
  d4f98835, 5784a2ec



Signed-off-by: default avatarSaalim Quadri <danascape@gmail.com>
parent a8396f62
Loading
Loading
Loading
Loading
Loading
+108 −50
Original line number Diff line number Diff line
@@ -18,9 +18,16 @@

package foundation.e.apps.ui.compose.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -28,14 +35,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -270,66 +277,113 @@ private fun PrimaryActionArea(
                .clickable(onClick = onShowMoreClick),
        )
        return
    } else {
        // render the primary action button
    }

    val accentColor = MaterialTheme.colorScheme.tertiary
    val hasProgress = uiState.progressFraction > 0f

    val labelTextColor = when {
        uiState.isFilledStyle -> MaterialTheme.colorScheme.onPrimary
    val targetContainerColor = when {
        hasProgress -> accentColor.copy(alpha = 0.12f)
        uiState.isFilledStyle && uiState.enabled -> accentColor
        uiState.isFilledStyle -> accentColor.copy(alpha = 0.12f)
        else -> Color.Transparent
    }

    val targetTextColor = when {
        hasProgress && uiState.progressFraction >= PROGRESS_TEXT_COLOR_THRESHOLD -> Color.White
        hasProgress -> accentColor
        uiState.isFilledStyle -> Color.White
        else -> accentColor
    }

    val buttonContent: @Composable () -> Unit = {
    val animatedContainerColor = animateColorAsState(
        targetValue = targetContainerColor,
        animationSpec = tween(durationMillis = ANIMATION_DURATION_MS),
        label = "containerColor"
    )

    val animatedTextColor = animateColorAsState(
        targetValue = targetTextColor,
        animationSpec = tween(durationMillis = ANIMATION_DURATION_MS),
        label = "textColor"
    )

    val animatedProgress = animateFloatAsState(
        targetValue = uiState.progressFraction.coerceIn(0f, 1f),
        animationSpec = tween(durationMillis = ANIMATION_DURATION_MS),
        label = "progressFraction"
    )

    Column(horizontalAlignment = Alignment.End) {
        val buttonShape = RoundedCornerShape(4.dp)
        val showBorder = !hasProgress && !uiState.isFilledStyle
        Box(
            modifier = Modifier
                .then(
                    if (hasProgress) {
                        Modifier.width(88.dp)
                    } else {
                        Modifier.widthIn(min = 88.dp)
                    }
                )
                .height(40.dp)
                .clip(buttonShape)
                .background(animatedContainerColor.value)
                .then(
                    if (showBorder) {
                        Modifier.border(1.dp, accentColor, buttonShape)
                    } else {
                        Modifier
                    }
                )
                .clickable(enabled = uiState.enabled, onClick = onPrimaryClick)
                .testTag(SearchResultListItemTestTags.PRIMARY_BUTTON),
            contentAlignment = Alignment.Center,
        ) {
            if (hasProgress) {
                Box(
                    modifier = Modifier
                        .fillMaxHeight()
                        .fillMaxWidth(animatedProgress.value.coerceIn(0f, 1f))
                        .align(Alignment.CenterStart)
                        .background(accentColor)
                )
            }

            val showSpinner = uiState.isInProgress && uiState.label.isBlank()
        if (showSpinner) {
            val animationKey = when {
                showSpinner -> "spinner"
                else -> uiState.label
            }
            AnimatedContent(
                targetState = animationKey,
                transitionSpec = {
                    fadeIn(animationSpec = tween(ANIMATION_DURATION_MS)) togetherWith
                        fadeOut(animationSpec = tween(ANIMATION_DURATION_MS))
                },
                label = "buttonContent"
            ) { targetKey ->
                when (targetKey) {
                    "spinner" -> {
                        CircularProgressIndicator(
                modifier = Modifier
                    .size(16.dp)
                    .testTag(SearchResultListItemTestTags.PRIMARY_PROGRESS),
                            modifier = Modifier.size(16.dp),
                            strokeWidth = 2.dp,
                color = labelTextColor,
                            color = animatedTextColor.value,
                        )
        } else {
                    }

                    else -> {
                        Text(
                text = uiState.label,
                            text = targetKey,
                            maxLines = 1,
                            overflow = TextOverflow.Clip,
                color = labelTextColor,
                            color = animatedTextColor.value,
                            style = MaterialTheme.typography.labelLarge,
                            modifier = Modifier.padding(horizontal = 16.dp),
                        )
                    }
                }

    Column(horizontalAlignment = Alignment.End) {
        val borderColor = when {
            uiState.isFilledStyle -> Color.Transparent
            uiState.enabled -> accentColor
            else -> accentColor.copy(alpha = 0.38f)
        }
        Button(
            onClick = onPrimaryClick,
            enabled = uiState.enabled,
            modifier = Modifier
                .height(40.dp)
                .testTag(SearchResultListItemTestTags.PRIMARY_BUTTON),
            shape = RoundedCornerShape(4.dp),
            colors = ButtonDefaults.buttonColors(
                containerColor = when {
                    uiState.isFilledStyle -> accentColor
                    else -> Color.Transparent
                },
                contentColor = labelTextColor,
                disabledContainerColor = when {
                    uiState.isFilledStyle -> accentColor.copy(alpha = 0.12f)
                    else -> Color.Transparent
                },
                disabledContentColor = labelTextColor.copy(alpha = 0.38f),
            ),
            border = BorderStroke(1.dp, borderColor),
            contentPadding = ButtonDefaults.ContentPadding,
        ) {
            buttonContent()
            }
        }

        if (showPrivacyScore) {
@@ -379,6 +433,7 @@ data class PrimaryActionUiState(
    val isFilledStyle: Boolean,
    val showMore: Boolean = false,
    val actionIntent: InstallButtonAction = InstallButtonAction.NoOp,
    val progressFraction: Float = 0f,
)

internal object SearchResultListItemTestTags {
@@ -391,6 +446,9 @@ internal object SearchResultListItemTestTags {
    const val PRIVACY_PROGRESS = "search_result_item_privacy_progress"
}

private const val ANIMATION_DURATION_MS = 250
private const val PROGRESS_TEXT_COLOR_THRESHOLD = 0.5f

// --- Previews ---

@Preview(showBackground = true)
+1 −1
Original line number Diff line number Diff line
@@ -615,7 +615,6 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState):
        isPrivacyLoading = false,
        primaryAction = PrimaryActionUiState(
            label = buttonState.label.text
                ?: buttonState.progressPercentText
                ?: buttonState.label.resId?.let { stringResource(id = it) }
                ?: "",
            enabled = buttonState.enabled,
@@ -623,6 +622,7 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState):
            isFilledStyle = buttonState.style == InstallButtonStyle.AccentFill,
            showMore = false,
            actionIntent = buttonState.actionIntent,
            progressFraction = buttonState.progressFraction,
        ),
        iconUrl = iconUrl,
        placeholderResId = null,
+1 −0
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ data class InstallButtonState(
    val style: InstallButtonStyle = InstallButtonStyle.AccentOutline,
    val showProgressBar: Boolean = false,
    val progressPercentText: String? = null,
    val progressFraction: Float = 0f,
    val actionIntent: InstallButtonAction = InstallButtonAction.NoOp,
    @StringRes val snackbarMessageId: Int? = null,
    val dialogType: InstallDialogType? = null,
+24 −5
Original line number Diff line number Diff line
@@ -32,7 +32,8 @@ fun mapAppToInstallState(input: InstallButtonStateInput): InstallButtonState {
        Status.INSTALLED -> mapInstalled()
        Status.UPDATABLE -> mapUpdatable(input)
        Status.UNAVAILABLE -> mapUnavailable(input)
        Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> mapDownloading(input, status)
        Status.QUEUED, Status.AWAITING -> mapQueued(status)
        Status.DOWNLOADING, Status.DOWNLOADED -> mapDownloading(input, status)
        Status.INSTALLING -> mapInstalling(status)
        Status.BLOCKED -> mapBlocked(input)
        Status.INSTALLATION_ISSUE -> mapInstallationIssue(input)
@@ -136,13 +137,27 @@ private fun mapUnavailablePaid(input: InstallButtonStateInput): InstallButtonSta
    }
}

private fun mapQueued(status: Status): InstallButtonState {
    return InstallButtonState(
        label = ButtonLabel(resId = R.string.cancel),
        progressPercentText = null,
        enabled = true,
        style = buildStyleFor(status, enabled = true),
        actionIntent = InstallButtonAction.CancelDownload,
        statusTag = StatusTag.Downloading,
        rawStatus = status,
    )
}

private fun mapDownloading(input: InstallButtonStateInput, status: Status): InstallButtonState {
    val fraction = input.progressPercent
        ?.coerceIn(PROGRESS_MIN, PROGRESS_MAX)
        ?.div(PROGRESS_MAX_FLOAT)
        ?: 0f
    return InstallButtonState(
        label = ButtonLabel(
            resId = if (input.percentLabel == null) R.string.cancel else null,
            text = input.percentLabel,
        ),
        label = ButtonLabel(resId = R.string.cancel),
        progressPercentText = input.percentLabel,
        progressFraction = fraction,
        enabled = true,
        style = buildStyleFor(status, enabled = true),
        actionIntent = InstallButtonAction.CancelDownload,
@@ -216,3 +231,7 @@ private fun buildStyleFor(status: Status, enabled: Boolean): InstallButtonStyle
        else -> InstallButtonStyle.Disabled
    }
}

private const val PROGRESS_MIN = 0
private const val PROGRESS_MAX = 100
private const val PROGRESS_MAX_FLOAT = 100f
+53 −25
Original line number Diff line number Diff line
@@ -48,9 +48,11 @@ import foundation.e.apps.ui.AppProgressViewModel
import foundation.e.apps.ui.MainActivityViewModel
import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment
import foundation.e.apps.ui.compose.screens.SearchScreen
import foundation.e.apps.ui.compose.state.ButtonLabel
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.compose.state.InstallButtonStateInput
import foundation.e.apps.ui.compose.state.InstallButtonStyle
import foundation.e.apps.ui.compose.state.PurchaseState
import foundation.e.apps.ui.compose.state.mapAppToInstallState
import foundation.e.apps.ui.compose.theme.AppTheme
@@ -101,17 +103,21 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
        val downloadProgress by appProgressViewModel.downloadProgress.observeAsState()
        val progressPercentMap by searchViewModel.progressPercentByKey.collectAsState()
        val statusByKey by searchViewModel.statusByKey.collectAsState()
        val pendingInstalls by searchViewModel.pendingInstalls.collectAsState()
        val selfPackageName = requireContext().packageName

        DownloadProgressEffect(downloadProgress)

        val installButtonStateProvider = buildInstallButtonStateProvider(
            InstallButtonProviderParams(
                user = user,
                isAnonymous = isAnonymous,
                progressPercentMap = progressPercentMap,
                statusByKey = statusByKey,
                pendingInstalls = pendingInstalls,
                selfPackageName = selfPackageName,
            )
        )

        SearchScreen(
            uiState = uiState,
@@ -148,35 +154,43 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
    }

    private fun buildInstallButtonStateProvider(
        user: User,
        isAnonymous: Boolean,
        progressPercentMap: Map<String, Int>,
        statusByKey: Map<String, Status>,
        selfPackageName: String,
        params: InstallButtonProviderParams,
    ): (Application) -> InstallButtonState {
        return { app ->
            val progressKey = progressKeyFor(app)
            val progressPercent = progressPercentMap[progressKey]
            val overrideStatus = statusByKey[progressKey]
            val progressPercent = params.progressPercentMap[progressKey]
            val overrideStatus = params.statusByKey[progressKey]
            val isPending = progressKey in params.pendingInstalls
            val purchaseState = purchaseStateFor(app)
            val isBlocked = appInfoFetchViewModel.isAppInBlockedList(app)
            val isUnsupported = isUnsupportedApp(app)

            // Show disabled state while waiting for install to register
            if (isPending && (overrideStatus == null || overrideStatus == Status.UNAVAILABLE)) {
                InstallButtonState(
                    label = ButtonLabel(),
                    enabled = false,
                    style = InstallButtonStyle.AccentOutline,
                    showProgressBar = true,
                    actionIntent = InstallButtonAction.NoOp,
                )
            } else {
                mapInstallButtonState(
                    app = app,
                    installButtonContext = InstallButtonContext(
                    user = user,
                    isAnonymous = isAnonymous,
                        user = params.user,
                        isAnonymous = params.isAnonymous,
                        isUnsupported = isUnsupported,
                        purchaseState = purchaseState,
                        progressPercent = progressPercent,
                        overrideStatus = overrideStatus,
                        isBlocked = isBlocked,
                    selfPackageName = selfPackageName,
                        selfPackageName = params.selfPackageName,
                    )
                )
            }
        }
    }

    private fun progressKeyFor(app: Application): String {
        return app.package_name.takeIf { it.isNotBlank() } ?: app._id
@@ -234,6 +248,15 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
        val selfPackageName: String,
    )

    private data class InstallButtonProviderParams(
        val user: User,
        val isAnonymous: Boolean,
        val progressPercentMap: Map<String, Int>,
        val statusByKey: Map<String, Status>,
        val pendingInstalls: Set<String>,
        val selfPackageName: String,
    )

    private fun copyProgress(progress: DownloadProgress): DownloadProgress {
        return DownloadProgress(
            totalSizeBytes = progress.totalSizeBytes.toMutableMap(),
@@ -247,15 +270,20 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
        when (action) {
            InstallButtonAction.Install,
            InstallButtonAction.UpdateSelfConfirm -> {
                searchViewModel.markPendingInstall(app)
                mainActivityViewModel.verifyUiFilter(app) {
                    if (mainActivityViewModel.shouldShowPaidAppsSnackBar(app)) {
                        searchViewModel.clearPendingInstall(app)
                        return@verifyUiFilter
                    }
                    mainActivityViewModel.getApplication(app)
                }
            }

            InstallButtonAction.CancelDownload -> mainActivityViewModel.cancelDownload(app)
            InstallButtonAction.CancelDownload -> {
                searchViewModel.clearPendingInstall(app)
                mainActivityViewModel.cancelDownload(app)
            }
            InstallButtonAction.OpenAppOrPwa -> {
                if (app.is_pwa) {
                    mainActivityViewModel.launchPwa(app)
Loading