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

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

feat: add Compose search status sync and install actions

- add status polling/reconciliation for Compose search paging flows
- wire install/open/cancel/purchase actions through InstallButtonAction to host fragment
- map InstallButtonState into Compose list items for accurate button labels/status
parent d2229b5c
Loading
Loading
Loading
Loading
+26 −0
Original line number Diff line number Diff line
@@ -114,6 +114,32 @@ class PwaManager @Inject constructor(
        context.startActivity(launchIntent)
    }

    /**
     * Return all installed PWA URLs from PWA Player.
     * Used for periodic status polling in Compose search to mirror legacy status detection.
     */
    fun getInstalledPwaUrls(): Set<String> {
        val installed = mutableSetOf<String>()
        context.contentResolver.query(
            Uri.parse(PWA_PLAYER),
            arrayOf("url"),
            null,
            null,
            null
        )?.use { cursor ->
            val urlIndex = cursor.getColumnIndex("url")
            if (urlIndex != -1) {
                while (cursor.moveToNext()) {
                    val url = cursor.getString(urlIndex)
                    if (!url.isNullOrBlank()) {
                        installed.add(url)
                    }
                }
            }
        }
        return installed
    }

    suspend fun installPWAApp(appInstall: AppInstall) {
        // Update status
        appInstall.status = Status.DOWNLOADING
+8 −6
Original line number Diff line number Diff line
@@ -58,6 +58,7 @@ import coil.compose.rememberImagePainter
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.compose.state.InstallButtonAction

@Composable
fun SearchResultListItem(
@@ -350,6 +351,7 @@ data class PrimaryActionUiState(
    val isInProgress: Boolean,
    val isFilledStyle: Boolean,
    val showMore: Boolean = false,
    val actionIntent: InstallButtonAction = InstallButtonAction.NoOp,
)

// --- Previews ---
+44 −20
Original line number Diff line number Diff line
@@ -56,12 +56,12 @@ 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 com.aurora.gplayapi.data.models.App
import foundation.e.apps.data.application.utils.toApplication
import androidx.compose.ui.platform.LocalContext
import foundation.e.apps.ui.compose.components.search.SearchErrorState
import foundation.e.apps.ui.compose.components.search.SearchResultListItemPlaceholder
import foundation.e.apps.ui.compose.components.search.SearchShimmerList
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.compose.state.InstallButtonStyle
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.ScrollPosition
import foundation.e.apps.ui.search.v2.SearchTabType
@@ -75,16 +75,17 @@ fun SearchResultsContent(
    selectedTab: SearchTabType,
    fossItems: LazyPagingItems<Application>?,
    pwaItems: LazyPagingItems<Application>?,
    playStoreItems: LazyPagingItems<App>? = null,
    playStoreItems: LazyPagingItems<Application>? = null,
    searchVersion: Int,
    getScrollPosition: (SearchTabType) -> ScrollPosition?,
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit,
    onTabSelected: (SearchTabType) -> Unit,
    modifier: Modifier = Modifier,
    onResultClick: (Application) -> Unit = {},
    onPrimaryActionClick: (Application) -> Unit = {},
    onPrimaryActionClick: (Application, InstallButtonAction) -> Unit = { _, _ -> },
    onShowMoreClick: (Application) -> Unit = {},
    onPrivacyClick: (Application) -> Unit = {},
    installButtonStateProvider: (Application) -> InstallButtonState,
) {
    if (tabs.isEmpty() || selectedTab !in tabs) {
        return
@@ -145,6 +146,7 @@ fun SearchResultsContent(
                        onPrimaryActionClick = onPrimaryActionClick,
                        onShowMoreClick = onShowMoreClick,
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
@@ -160,6 +162,7 @@ fun SearchResultsContent(
                        onPrimaryActionClick = onPrimaryActionClick,
                        onShowMoreClick = onShowMoreClick,
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
@@ -174,6 +177,7 @@ fun SearchResultsContent(
                        onPrimaryActionClick = onPrimaryActionClick,
                        onShowMoreClick = onShowMoreClick,
                        onPrivacyClick = onPrivacyClick,
                        installButtonStateProvider = installButtonStateProvider,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
@@ -227,17 +231,17 @@ private fun SearchTabs(

@Composable
private fun PagingPlayStoreResultList(
    items: LazyPagingItems<App>?,
    items: LazyPagingItems<Application>?,
    searchVersion: Int,
    getScrollPosition: (SearchTabType) -> ScrollPosition?,
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit,
    onItemClick: (Application) -> Unit,
    onPrimaryActionClick: (Application) -> Unit,
    onPrimaryActionClick: (Application, InstallButtonAction) -> Unit,
    onShowMoreClick: (Application) -> Unit,
    onPrivacyClick: (Application) -> Unit,
    installButtonStateProvider: (Application) -> InstallButtonState,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    val lazyItems = items ?: return
    val saved = getScrollPosition(SearchTabType.STANDARD_APPS)
    val listState = rememberSaveable(
@@ -300,18 +304,23 @@ private fun PagingPlayStoreResultList(
                        count = lazyItems.itemCount,
                        key = { index ->
                            val item = lazyItems.peek(index)
                            item?.packageName.takeIf { !it.isNullOrBlank() }
                                ?: item?.id.toString()
                            item?.package_name.takeIf { !it.isNullOrBlank() }
                                ?: item?._id.toString()
                        },
                    ) { index ->
                        val app = lazyItems[index]
                        if (app != null) {
                            val application = app.toApplication(context)
                        val application = lazyItems[index]
                        if (application != null) {
                            val uiState = application.toSearchResultUiState(installButtonStateProvider(application))
                            SearchResultListItem(
                                application = application,
                                uiState = application.toSearchResultUiState(),
                                uiState = uiState,
                                onItemClick = onItemClick,
                                onPrimaryActionClick = onPrimaryActionClick,
                                onPrimaryActionClick = {
                                    onPrimaryActionClick(
                                        application,
                                        uiState.primaryAction.actionIntent
                                    )
                                },
                                onShowMoreClick = onShowMoreClick,
                                onPrivacyClick = onPrivacyClick,
                                modifier = Modifier.fillMaxWidth(),
@@ -347,9 +356,10 @@ private fun PagingSearchResultList(
    getScrollPosition: (SearchTabType) -> ScrollPosition?,
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit,
    onItemClick: (Application) -> Unit,
    onPrimaryActionClick: (Application) -> Unit,
    onPrimaryActionClick: (Application, InstallButtonAction) -> Unit,
    onShowMoreClick: (Application) -> Unit,
    onPrivacyClick: (Application) -> Unit,
    installButtonStateProvider: (Application) -> InstallButtonState,
    modifier: Modifier = Modifier,
) {
    val lazyItems = items ?: return
@@ -417,11 +427,17 @@ private fun PagingSearchResultList(
                    ) { index ->
                        val application = lazyItems[index]
                        if (application != null) {
                            val uiState = application.toSearchResultUiState(installButtonStateProvider(application))
                            SearchResultListItem(
                                application = application,
                                uiState = application.toSearchResultUiState(),
                                uiState = uiState,
                                onItemClick = onItemClick,
                                onPrimaryActionClick = onPrimaryActionClick,
                                onPrimaryActionClick = {
                                    onPrimaryActionClick(
                                        application,
                                        uiState.primaryAction.actionIntent
                                    )
                                },
                                onShowMoreClick = onShowMoreClick,
                                onPrivacyClick = onPrivacyClick,
                                modifier = Modifier.fillMaxWidth(),
@@ -450,7 +466,7 @@ private fun PagingSearchResultList(
}

@Composable
private fun Application.toSearchResultUiState(): SearchResultListItemState {
private fun Application.toSearchResultUiState(buttonState: InstallButtonState): SearchResultListItemState {
    if (isPlaceHolder) {
        return SearchResultListItemState(
            author = "",
@@ -494,7 +510,14 @@ private fun Application.toSearchResultUiState(): SearchResultListItemState {
        privacyScore = "",
        showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec.
        isPrivacyLoading = false,
        primaryAction = resolvePrimaryActionState(this),
        primaryAction = PrimaryActionUiState(
            label = buttonState.label.text ?: buttonState.label.resId?.let { stringResource(id = it) } ?: "",
            enabled = buttonState.enabled,
            isInProgress = buttonState.isInProgress(),
            isFilledStyle = buttonState.style == InstallButtonStyle.AccentFill,
            showMore = false,
            actionIntent = buttonState.actionIntent,
        ),
        iconUrl = iconUrl,
        placeholderResId = null,
        isPlaceholder = false,
@@ -570,6 +593,7 @@ private fun SearchResultsContentPreview() {
            getScrollPosition = { null },
            onScrollPositionChange = { _, _, _ -> },
            onTabSelected = {},
            installButtonStateProvider = { InstallButtonState() },
        )
    }
}
+8 −3
Original line number Diff line number Diff line
@@ -36,7 +36,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.aurora.gplayapi.data.models.App
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.ui.compose.components.SearchInitialState
import foundation.e.apps.ui.compose.components.SearchResultsContent
@@ -45,6 +44,8 @@ import foundation.e.apps.ui.compose.components.SearchTopBar
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.ScrollPosition
import foundation.e.apps.ui.search.v2.SearchTabType
import foundation.e.apps.ui.compose.state.InstallButtonAction
import foundation.e.apps.ui.compose.state.InstallButtonState
import foundation.e.apps.ui.search.v2.SearchUiState
import kotlinx.coroutines.flow.Flow

@@ -60,7 +61,7 @@ fun SearchScreen(
    onTabSelected: (SearchTabType) -> Unit,
    fossPaging: Flow<PagingData<Application>>? = null,
    pwaPaging: Flow<PagingData<Application>>? = null,
    playStorePaging: Flow<PagingData<App>>? = null,
    playStorePaging: Flow<PagingData<Application>>? = null,
    searchVersion: Int = 0,
    getScrollPosition: (SearchTabType) -> ScrollPosition? = { null },
    onScrollPositionChange: (SearchTabType, Int, Int) -> Unit = { _, _, _ -> },
@@ -68,6 +69,8 @@ fun SearchScreen(
    onPrimaryActionClick: (Application) -> Unit = {},
    onShowMoreClick: (Application) -> Unit = {},
    onPrivacyClick: (Application) -> Unit = {},
    onPrimaryAction: (Application, InstallButtonAction) -> Unit = { _, _ -> },
    installButtonStateProvider: (Application) -> InstallButtonState,
) {
    val focusManager = LocalFocusManager.current
    val focusRequester = remember { FocusRequester() }
@@ -118,9 +121,10 @@ fun SearchScreen(
                        .fillMaxWidth()
                        .padding(top = 8.dp),
                    onResultClick = onResultClick,
                    onPrimaryActionClick = onPrimaryActionClick,
                    onPrimaryActionClick = onPrimaryAction,
                    onShowMoreClick = onShowMoreClick,
                    onPrivacyClick = onPrivacyClick,
                    installButtonStateProvider = installButtonStateProvider,
                )
            } else {
                SearchInitialState(
@@ -188,6 +192,7 @@ private fun SearchScreenPreview() {
            onTabSelected = {},
            fossPaging = null,
            pwaPaging = null,
            installButtonStateProvider = { InstallButtonState() },
        )
    }
}
+285 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.ui.compose.state

import androidx.annotation.StringRes
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.enums.User
import foundation.e.apps.install.pkg.InstallerService

/**
 * Central UI contract for the primary action button in search results.
 * UI layers render purely from this state; business logic must not leak into Composables.
 */
data class InstallButtonState(
    val label: ButtonLabel = ButtonLabel(),
    val enabled: Boolean = true,
    val style: InstallButtonStyle = InstallButtonStyle.AccentOutline,
    val showProgressBar: Boolean = false,
    val progressPercentText: String? = null,
    val actionIntent: InstallButtonAction = InstallButtonAction.NoOp,
    @StringRes val snackbarMessageId: Int? = null,
    val dialogType: InstallDialogType? = null,
    val statusTag: StatusTag = StatusTag.Unknown,
    val rawStatus: Status = Status.UNAVAILABLE,
) {
    fun isInProgress(): Boolean {
        return showProgressBar ||
            actionIntent == InstallButtonAction.CancelDownload ||
            rawStatus in Status.downloadStatuses || rawStatus == Status.INSTALLING
    }
}

/**
 * UI label that can be backed by a string resource or a literal string (e.g., price).
 */
data class ButtonLabel(
    @StringRes val resId: Int? = null,
    val text: String? = null,
)

/**
 * Visual treatment tokens mapped to Compose theme in the UI layer.
 */
enum class InstallButtonStyle {
    AccentFill,      // Accent background, white text (matches installed/updatable legacy state)
    AccentOutline,   // Transparent background, accent stroke + text
    Disabled,        // Light-grey stroke + text
}

/**
 * Intent describing the side-effect the UI host should execute on click.
 */
enum class InstallButtonAction {
    Install,
    CancelDownload,
    OpenAppOrPwa,
    UpdateSelfConfirm,
    ShowPaidDialog,
    ShowBlockedSnackbar,
    NoOp,
}

/**
 * Dialog variants initiated from the button state when action requires confirmation.
 */
enum class InstallDialogType {
    SelfUpdateConfirmation,
    PaidAppDialog,
}

/**
 * Lightweight status marker useful for tests and debugging to assert mapping branch taken.
 */
enum class StatusTag {
    Installed,
    Updatable,
    UnavailableFree,
    UnavailablePaid,
    UnavailableUnsupported,
    Downloading,
    Installing,
    Blocked,
    InstallationIssue,
    Unknown,
}

/**
 * Purchase resolution states, mirroring legacy branching.
 */
sealed class PurchaseState {
    object Unknown : PurchaseState()
    object Loading : PurchaseState()
    object Purchased : PurchaseState()
    object NotPurchased : PurchaseState()
}

/**
 * Map raw application + contextual signals into a single button state.
 * Keep pure: no side effects; callers handle actions.
 */
fun mapAppToInstallState(
    app: Application,
    user: User,
    isAnonymousUser: Boolean,
    isUnsupported: Boolean,
    faultyResult: Pair<Boolean, String>?,
    purchaseState: PurchaseState,
    progressPercent: Int?,
    isSelfUpdate: Boolean,
): InstallButtonState {
    val percentLabel = progressPercent?.takeIf { it in 0..100 }?.let { "$it%" }

    return when (app.status) {
        Status.INSTALLED -> InstallButtonState(
            label = ButtonLabel(resId = R.string.open),
            enabled = true,
            style = styleFor(status = Status.INSTALLED, enabled = true),
            actionIntent = InstallButtonAction.OpenAppOrPwa,
            statusTag = StatusTag.Installed,
        )

        Status.UPDATABLE -> {
            val unsupported = isUnsupported
            InstallButtonState(
                label = ButtonLabel(resId = if (unsupported) R.string.not_available else R.string.update),
                enabled = true,
                style = styleFor(status = Status.UPDATABLE, enabled = true),
                actionIntent = if (unsupported) InstallButtonAction.NoOp
                else if (isSelfUpdate) InstallButtonAction.UpdateSelfConfirm else InstallButtonAction.Install,
                dialogType = if (!unsupported && isSelfUpdate) InstallDialogType.SelfUpdateConfirmation else null,
                statusTag = StatusTag.Updatable,
            )
        }

        Status.UNAVAILABLE -> {
            when {
                isUnsupported -> InstallButtonState(
                    label = ButtonLabel(resId = R.string.not_available),
                    enabled = true,
                    style = styleFor(Status.UNAVAILABLE, enabled = true),
                    actionIntent = InstallButtonAction.NoOp,
                    statusTag = StatusTag.UnavailableUnsupported,
                )

                app.isFree -> InstallButtonState(
                    label = ButtonLabel(resId = R.string.install),
                    enabled = true,
                    style = styleFor(Status.UNAVAILABLE, enabled = true),
                    actionIntent = InstallButtonAction.Install,
                    statusTag = StatusTag.UnavailableFree,
                )

                isAnonymousUser -> InstallButtonState(
                    label = ButtonLabel(text = app.price),
                    enabled = true,
                    style = styleFor(Status.UNAVAILABLE, enabled = true),
                    actionIntent = InstallButtonAction.ShowPaidDialog,
                    dialogType = InstallDialogType.PaidAppDialog,
                    statusTag = StatusTag.UnavailablePaid,
                )

                else -> {
                    when (purchaseState) {
                        PurchaseState.Loading, PurchaseState.Unknown -> InstallButtonState(
                            label = ButtonLabel(text = ""),
                            enabled = false,
                            style = styleFor(Status.UNAVAILABLE, enabled = false),
                            showProgressBar = true,
                            actionIntent = InstallButtonAction.NoOp,
                            statusTag = StatusTag.UnavailablePaid,
                        )

                        PurchaseState.Purchased -> InstallButtonState(
                            label = ButtonLabel(resId = R.string.install),
                            enabled = true,
                            style = styleFor(Status.UNAVAILABLE, enabled = true),
                            actionIntent = InstallButtonAction.Install,
                            statusTag = StatusTag.UnavailablePaid,
                        )

                        PurchaseState.NotPurchased -> InstallButtonState(
                            label = ButtonLabel(text = app.price),
                            enabled = true,
                            style = styleFor(Status.UNAVAILABLE, enabled = true),
                            actionIntent = InstallButtonAction.ShowPaidDialog,
                            dialogType = InstallDialogType.PaidAppDialog,
                            statusTag = StatusTag.UnavailablePaid,
                        )
                    }
                }
            }
        }

        Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> InstallButtonState(
            label = ButtonLabel(resId = if (percentLabel == null) R.string.cancel else null, text = percentLabel),
            progressPercentText = percentLabel,
            enabled = true,
            style = styleFor(app.status, enabled = true),
            actionIntent = InstallButtonAction.CancelDownload,
            statusTag = StatusTag.Downloading,
            rawStatus = app.status,
        )

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

        Status.BLOCKED -> {
            val messageId = when (user) {
                User.ANONYMOUS, User.NO_GOOGLE, User.UNAVAILABLE -> R.string.install_blocked_anonymous
                User.GOOGLE -> R.string.install_blocked_google
            }
            InstallButtonState(
                label = defaultBlockedLabel(app),
                enabled = true,
                style = styleFor(Status.BLOCKED, enabled = true),
                actionIntent = InstallButtonAction.ShowBlockedSnackbar,
                snackbarMessageId = messageId,
                statusTag = StatusTag.Blocked,
                rawStatus = app.status,
            )
        }

        Status.INSTALLATION_ISSUE -> {
            val faulty = faultyResult ?: (false to "")
            val incompatible = faulty.second == InstallerService.INSTALL_FAILED_UPDATE_INCOMPATIBLE
            InstallButtonState(
                label = ButtonLabel(resId = if (incompatible) R.string.update else R.string.retry),
                enabled = !faulty.first,
                style = styleFor(Status.INSTALLATION_ISSUE, enabled = !faulty.first),
                actionIntent = InstallButtonAction.Install,
                statusTag = StatusTag.InstallationIssue,
                rawStatus = app.status,
            )
        }

        else -> InstallButtonState(
            label = ButtonLabel(resId = R.string.install),
            enabled = true,
            style = InstallButtonStyle.AccentOutline,
            actionIntent = InstallButtonAction.NoOp,
            statusTag = StatusTag.Unknown,
            rawStatus = app.status,
        )
    }
}

private fun defaultBlockedLabel(app: Application): ButtonLabel {
    val literal = app.price.takeIf { it.isNotBlank() }
    return if (literal != null) ButtonLabel(text = literal) else ButtonLabel(resId = R.string.install)
}

private fun styleFor(status: Status, enabled: Boolean): InstallButtonStyle {
    return when {
        status == Status.INSTALLED || status == Status.UPDATABLE -> {
            if (enabled) InstallButtonStyle.AccentFill else InstallButtonStyle.Disabled
        }

        enabled -> InstallButtonStyle.AccentOutline
        else -> InstallButtonStyle.Disabled
    }
}
Loading