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

Verified Commit ea9be74d authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

fix: refine V2 install button state and UI

- Track status overrides in SearchViewModelV2
- Handle blocked, paid, and unsupported apps in SearchFragmentV2
- Update SearchResultListItem button styling and text colors
- Allow status overrides in InstallButtonStateMapper
parent bb4346dd
Loading
Loading
Loading
Loading
+25 −8
Original line number Diff line number Diff line
@@ -271,36 +271,53 @@ private fun PrivacyBadge(

    val accentColor = MaterialTheme.colorScheme.tertiary

    val labelTextColor = when {
        uiState.isFilledStyle -> MaterialTheme.colorScheme.onPrimary
        else -> accentColor
    }

    val buttonContent: @Composable () -> Unit = {
        if (uiState.isInProgress) {
        val showSpinner = uiState.isInProgress && uiState.label.isBlank()
        if (showSpinner) {
            CircularProgressIndicator(
                modifier = Modifier.size(16.dp),
                strokeWidth = 2.dp,
                color = accentColor,
                color = labelTextColor,
            )
        } else {
            Text(
                text = uiState.label,
                maxLines = 1,
                overflow = TextOverflow.Clip,
                color = accentColor,
                color = labelTextColor,
            )
        }
    }

    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),
            shape = RoundedCornerShape(4.dp),
            colors = ButtonDefaults.buttonColors(
                containerColor = Color.Transparent,
                contentColor = accentColor,
                disabledContainerColor = Color.Transparent,
                disabledContentColor = accentColor.copy(alpha = 0.38f),
                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, if (uiState.enabled) accentColor else accentColor.copy(alpha = 0.38f)),
            border = BorderStroke(1.dp, borderColor),
            contentPadding = ButtonDefaults.ContentPadding,
        ) {
            buttonContent()
+4 −1
Original line number Diff line number Diff line
@@ -511,7 +511,10 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState):
        showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec.
        isPrivacyLoading = false,
        primaryAction = PrimaryActionUiState(
            label = buttonState.label.text ?: buttonState.label.resId?.let { stringResource(id = it) } ?: "",
            label = buttonState.label.text
                ?: buttonState.progressPercentText
                ?: buttonState.label.resId?.let { stringResource(id = it) }
                ?: "",
            enabled = buttonState.enabled,
            isInProgress = buttonState.isInProgress(),
            isFilledStyle = buttonState.style == InstallButtonStyle.AccentFill,
+7 −5
Original line number Diff line number Diff line
@@ -125,10 +125,12 @@ fun mapAppToInstallState(
    purchaseState: PurchaseState,
    progressPercent: Int?,
    isSelfUpdate: Boolean,
    overrideStatus: Status? = null,
): InstallButtonState {
    val status = overrideStatus ?: app.status
    val percentLabel = progressPercent?.takeIf { it in 0..100 }?.let { "$it%" }

    return when (app.status) {
    return when (status) {
        Status.INSTALLED -> InstallButtonState(
            label = ButtonLabel(resId = R.string.open),
            enabled = true,
@@ -213,19 +215,19 @@ fun mapAppToInstallState(
            label = ButtonLabel(resId = if (percentLabel == null) R.string.cancel else null, text = percentLabel),
            progressPercentText = percentLabel,
            enabled = true,
            style = styleFor(app.status, enabled = true),
            style = styleFor(status, enabled = true),
            actionIntent = InstallButtonAction.CancelDownload,
            statusTag = StatusTag.Downloading,
            rawStatus = app.status,
            rawStatus = status,
        )

        Status.INSTALLING -> InstallButtonState(
            label = ButtonLabel(resId = R.string.installing),
            enabled = false,
            style = styleFor(app.status, enabled = false),
            style = styleFor(status, enabled = false),
            actionIntent = InstallButtonAction.NoOp,
            statusTag = StatusTag.Installing,
            rawStatus = app.status,
            rawStatus = status,
        )

        Status.BLOCKED -> {
+67 −5
Original line number Diff line number Diff line
@@ -2,8 +2,10 @@ package foundation.e.apps.ui.search.v2

import android.os.Bundle
import android.view.View
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
@@ -15,7 +17,11 @@ import dagger.hilt.android.AndroidEntryPoint
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.enums.User
import foundation.e.apps.data.enums.isInitialized
import foundation.e.apps.data.enums.isUnFiltered
import foundation.e.apps.install.download.data.DownloadProgress
import foundation.e.apps.ui.MainActivityViewModel
import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment
import foundation.e.apps.ui.compose.screens.SearchScreen
@@ -24,12 +30,16 @@ import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.compose.state.PurchaseState
import foundation.e.apps.ui.compose.state.mapAppToInstallState
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.AppProgressViewModel
import foundation.e.apps.ui.AppInfoFetchViewModel

@AndroidEntryPoint
class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {

    private val searchViewModelV2: SearchViewModelV2 by viewModels()
    private val mainActivityViewModel: MainActivityViewModel by activityViewModels()
    private val appProgressViewModel: AppProgressViewModel by viewModels()
    private val appInfoFetchViewModel: AppInfoFetchViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val composeView = view.findViewById<ComposeView>(R.id.composeView)
@@ -39,17 +49,50 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
                val uiState by searchViewModelV2.uiState.collectAsState()
                val user = mainActivityViewModel.getUser()
                val isAnonymous = user == User.ANONYMOUS
                val downloadProgress by appProgressViewModel.downloadProgress.observeAsState()
                val progressPercentMap by searchViewModelV2.progressPercentByKey.collectAsState()
                val statusByKey by searchViewModelV2.statusByKey.collectAsState()

                LaunchedEffect(downloadProgress) {
                    downloadProgress?.let {
                        searchViewModelV2.updateDownloadProgress(copyProgress(it))
                    }
                }

                val installButtonStateProvider: (Application) -> InstallButtonState = { app ->
                    val progressKey = app.package_name.takeIf { it.isNotBlank() } ?: app._id
                    val progressPercent = progressPercentMap[progressKey]
                    val overrideStatus = statusByKey[progressKey]
                    val purchaseState = when {
                        app.isFree -> PurchaseState.Unknown
                        app.isPurchased -> PurchaseState.Purchased
                        else -> PurchaseState.NotPurchased
                    }
                    val isBlocked = appInfoFetchViewModel.isAppInBlockedList(app)
                    val isUnsupported = app.filterLevel.isInitialized() && !app.filterLevel.isUnFiltered()
                    val originalStatus = app.status
                    val effectiveApp = when {
                        isBlocked -> {
                            app.status = Status.BLOCKED
                            app
                        }
                        else -> app
                    }
                    mapAppToInstallState(
                        app = app,
                        app = effectiveApp,
                        user = user,
                        isAnonymousUser = isAnonymous,
                        isUnsupported = false, // unsupported requires UI dialog; handled on action
                        isUnsupported = isUnsupported,
                        faultyResult = null,
                        purchaseState = PurchaseState.Unknown,
                        progressPercent = null,
                        purchaseState = purchaseState,
                        progressPercent = progressPercent,
                        isSelfUpdate = app.package_name == requireContext().packageName,
                        overrideStatus = overrideStatus,
                    )
                        .also {
                            // Restore original status to avoid mutating shared paging instance.
                            app.status = originalStatus
                        }
                }

                SearchScreen(
@@ -79,6 +122,15 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
        }
    }

    private fun copyProgress(progress: DownloadProgress): DownloadProgress {
        return DownloadProgress(
            totalSizeBytes = progress.totalSizeBytes.toMutableMap(),
            bytesDownloadedSoFar = progress.bytesDownloadedSoFar.toMutableMap(),
            status = progress.status.toMutableMap(),
            downloadId = progress.downloadId
        )
    }

    private fun handlePrimaryAction(app: Application, action: InstallButtonAction) {
        when (action) {
            InstallButtonAction.Install,
@@ -101,7 +153,10 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
                }
            }
            InstallButtonAction.ShowPaidDialog -> {
                showPaidAppMessage(app)
                when (mainActivityViewModel.getUser()) {
                    User.GOOGLE -> showPaidAppMessage(app)
                    else -> showPaidSnackbar()
                }
            }
            InstallButtonAction.ShowBlockedSnackbar -> {
                showBlockedSnackbar(app)
@@ -126,6 +181,13 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
        ).show(childFragmentManager, "SearchFragmentV2")
    }

    private fun showPaidSnackbar() {
        view?.let {
            Snackbar.make(it, getString(R.string.paid_app_anonymous_message), Snackbar.LENGTH_SHORT)
                .show()
        }
    }

    private fun showBlockedSnackbar(application: Application) {
        val errorMsg = when (mainActivityViewModel.getUser()) {
            User.ANONYMOUS,
+46 −6
Original line number Diff line number Diff line
@@ -42,8 +42,7 @@ import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallStatusReconciler
import foundation.e.apps.ui.compose.state.InstallStatusStream
import foundation.e.apps.ui.compose.state.StatusSnapshot
import foundation.e.apps.ui.MainActivityViewModel
import androidx.fragment.app.Fragment
import androidx.lifecycle.asFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -61,6 +60,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import foundation.e.apps.data.enums.Status

private const val SUGGESTION_DEBOUNCE_MS = 200L

@@ -119,6 +119,10 @@ class SearchViewModelV2 @Inject constructor(
    private val _scrollPositions = MutableStateFlow<Map<SearchTabType, ScrollPosition>>(emptyMap())
    private val statusSnapshot = MutableStateFlow<StatusSnapshot?>(null)
    private val downloadProgress = MutableStateFlow<DownloadProgress?>(null)
    private val _progressPercentByKey = MutableStateFlow<Map<String, Int>>(emptyMap())
    val progressPercentByKey: StateFlow<Map<String, Int>> = _progressPercentByKey.asStateFlow()
    private val _statusByKey = MutableStateFlow<Map<String, Status>>(emptyMap())
    val statusByKey: StateFlow<Map<String, Status>> = _statusByKey.asStateFlow()

    val fossPagingFlow = buildCleanApkPagingFlow(
        tab = SearchTabType.OPEN_SOURCE,
@@ -327,6 +331,10 @@ class SearchViewModelV2 @Inject constructor(
        // TODO: wire DownloadProgress from AppProgressViewModel when available.
    }

    fun updateDownloadProgress(progress: DownloadProgress?) {
        downloadProgress.value = progress
    }

    private fun resolveVisibleTabs(): List<SearchTabType> = buildList {
        if (appLoungePreference.isPlayStoreSelected()) add(SearchTabType.STANDARD_APPS)
        if (appLoungePreference.isOpenSourceSelected()) add(SearchTabType.OPEN_SOURCE)
@@ -388,24 +396,56 @@ class SearchViewModelV2 @Inject constructor(
    private fun Flow<PagingData<Application>>.withStatus(): Flow<PagingData<Application>> =
        combine(
            this,
            statusSnapshot.filterNotNull().distinctUntilChanged()
        ) { paging: PagingData<Application>, snapshot: StatusSnapshot ->
            paging.map { app -> reconcile(app, snapshot) }
            statusSnapshot.filterNotNull(),
            downloadProgress
        ) { paging: PagingData<Application>, snapshot: StatusSnapshot, progress: DownloadProgress? ->
            paging.map { app -> reconcile(app, snapshot, progress) }
        }

    private suspend fun reconcile(
        app: Application,
        snapshot: StatusSnapshot? = statusSnapshot.value,
        progress: DownloadProgress? = downloadProgress.value,
    ): Application {
        val safeSnapshot = snapshot ?: return app
        val result = installStatusReconciler.reconcile(
            app = app,
            snapshot = safeSnapshot,
            progress = downloadProgress.value
            progress = progress
        )
        recordProgress(result.application, result.progressPercent)
        recordStatus(result.application)
        return result.application
    }

    fun progressPercentFor(app: Application): Int? {
        return _progressPercentByKey.value[keyFor(app)]
    }

    private fun recordProgress(app: Application, percent: Int?) {
        val key = keyFor(app)
        _progressPercentByKey.update { current ->
            when {
                percent == null && current.containsKey(key) -> current - key
                percent != null && current[key] != percent -> current + (key to percent)
                else -> current
            }
        }
    }

    fun statusFor(app: Application): Status? = _statusByKey.value[keyFor(app)]

    private fun recordStatus(app: Application) {
        val key = keyFor(app)
        _statusByKey.update { current ->
            if (current[key] != app.status) current + (key to app.status) else current
        }
    }

    private fun keyFor(app: Application): String {
        return app.package_name.takeIf { it.isNotBlank() } ?: app._id
    }

    companion object {
        private const val DEFAULT_PLAY_STORE_PAGE_SIZE = 20
        private val STORE_PREFERENCE_KEYS = setOf(