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

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

feat: show search result item in list

parent cf93370d
Loading
Loading
Loading
Loading
Loading
+168 −13
Original line number Diff line number Diff line
@@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Divider
@@ -45,6 +45,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import kotlinx.coroutines.launch
@@ -53,8 +57,12 @@ import kotlinx.coroutines.launch
fun SearchResultsContent(
    tabs: List<SearchTabType>,
    selectedTab: SearchTabType,
    resultsByTab: Map<SearchTabType, List<String>>,
    resultsByTab: Map<SearchTabType, List<Application>>,
    onTabSelected: (SearchTabType) -> Unit,
    onResultClick: (Application) -> Unit = {},
    onPrimaryActionClick: (Application) -> Unit = {},
    onShowMoreClick: (Application) -> Unit = {},
    onPrivacyClick: (Application) -> Unit = {},
    modifier: Modifier = Modifier,
) {
    if (tabs.isEmpty() || selectedTab !in tabs) {
@@ -107,6 +115,10 @@ fun SearchResultsContent(
            val items = resultsByTab[tab].orEmpty()
            SearchResultList(
                items = items,
                onItemClick = onResultClick,
                onPrimaryActionClick = onPrimaryActionClick,
                onShowMoreClick = onShowMoreClick,
                onPrivacyClick = onPrivacyClick,
                modifier = Modifier.fillMaxSize(),
            )
        }
@@ -158,27 +170,130 @@ private fun SearchTabs(

@Composable
private fun SearchResultList(
    items: List<String>,
    items: List<Application>,
    onItemClick: (Application) -> Unit,
    onPrimaryActionClick: (Application) -> Unit,
    onShowMoreClick: (Application) -> Unit,
    onPrivacyClick: (Application) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        items(
        itemsIndexed(
            items = items,
            key = { item -> item },
        ) { item ->
            Text(
                text = item,
                style = MaterialTheme.typography.bodyLarge,
                color = MaterialTheme.colorScheme.onBackground,
            key = { index, item ->
                item._id.takeIf { it.isNotBlank() }
                    ?: item.package_name.takeIf { it.isNotBlank() }
                    ?: "${item.name}-$index"
            },
        ) { _, application ->
            SearchResultListItem(
                application = application,
                uiState = application.toSearchResultUiState(),
                onItemClick = onItemClick,
                onPrimaryActionClick = onPrimaryActionClick,
                onShowMoreClick = onShowMoreClick,
                onPrivacyClick = onPrivacyClick,
                modifier = Modifier.fillMaxWidth(),
            )
        }
    }
}

@Composable
private fun Application.toSearchResultUiState(): SearchResultListItemState {
    if (isPlaceHolder) {
        return SearchResultListItemState(
            author = "",
            ratingText = "",
            showRating = false,
            sourceTag = "",
            showSourceTag = false,
            privacyScore = "",
            showPrivacyScore = false,
            isPrivacyLoading = false,
            primaryAction = PrimaryActionUiState(
                label = "",
                enabled = false,
                isInProgress = false,
                isFilledStyle = true,
            ),
            iconUrl = null,
            placeholderResId = null,
            isPlaceholder = true,
        )
    }

    val ratingText = when {
        source == Source.OPEN_SOURCE || source == Source.PWA || isSystemApp -> ""
        ratings.usageQualityScore >= 0 -> String.format("%.1f", ratings.usageQualityScore)
        else -> stringResource(id = R.string.not_available)
    }

    val sourceTagText = source.toString()

    return SearchResultListItemState(
        author = author.ifBlank { package_name },
        ratingText = ratingText,
        showRating = ratingText.isNotBlank(),
        sourceTag = sourceTagText,
        showSourceTag = false,
        privacyScore = "",
        showPrivacyScore = false, // Privacy scores are disabled on Search per functional spec.
        isPrivacyLoading = false,
        primaryAction = resolvePrimaryActionState(this),
        iconUrl = icon_image_path.takeIf { it.isNotBlank() },
        placeholderResId = null,
        isPlaceholder = false,
    )
}

@Composable
private fun resolvePrimaryActionState(application: Application): PrimaryActionUiState {
    val label = when (application.status) {
        Status.INSTALLED -> stringResource(id = R.string.open)
        Status.UPDATABLE -> stringResource(id = R.string.update)
        Status.INSTALLING -> stringResource(id = R.string.installing)
        Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> stringResource(id = R.string.cancel)
        Status.INSTALLATION_ISSUE -> stringResource(id = R.string.retry)
        Status.PURCHASE_NEEDED -> application.price.ifBlank { stringResource(id = R.string.install) }
        Status.BLOCKED -> stringResource(id = R.string.install)
        Status.UNAVAILABLE -> {
            if (!application.isFree && !application.isPurchased) {
                application.price.ifBlank { stringResource(id = R.string.install) }
            } else {
                stringResource(id = R.string.install)
            }
        }
    }

    val isInProgress = when (application.status) {
        Status.INSTALLING, Status.DOWNLOADING, Status.DOWNLOADED, Status.QUEUED, Status.AWAITING -> true
        else -> false
    }

    val isEnabled = when (application.status) {
        Status.INSTALLING -> false
        else -> true
    }

    return PrimaryActionUiState(
        label = label,
        enabled = isEnabled,
        isInProgress = isInProgress,
        isFilledStyle = true,
        showMore = false,
    )
}

private fun formatPrivacyScore(score: Int): String {
    if (score < 0) return ""
    val clamped = score.coerceIn(0, 10)
    return String.format("%02d/10", clamped)
}

@StringRes
private fun SearchTabType.toLabelRes(): Int = when (this) {
    SearchTabType.STANDARD_APPS -> R.string.search_tab_standard_apps
@@ -198,9 +313,49 @@ private fun SearchResultsContentPreview() {
            ),
            selectedTab = SearchTabType.OPEN_SOURCE,
            resultsByTab = mapOf(
                SearchTabType.STANDARD_APPS to listOf("Standard app 1 for Firefox", "Standard app 2 for Firefox"),
                SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Firefox"),
                SearchTabType.WEB_APPS to listOf("Web app 1 for Firefox", "Web app 2 for Firefox"),
                SearchTabType.STANDARD_APPS to listOf(
                    Application(
                        name = "Standard app 1 for Firefox",
                        author = "Author 1",
                        package_name = "com.example.standard1",
                        ratings = Ratings(usageQualityScore = 4.4),
                        source = Source.PLAY_STORE,
                        status = Status.UNAVAILABLE,
                    ),
                    Application(
                        name = "Standard app 2 for Firefox",
                        author = "Author 2",
                        package_name = "com.example.standard2",
                        ratings = Ratings(usageQualityScore = 4.0),
                        source = Source.PLAY_STORE,
                        status = Status.DOWNLOADING,
                    ),
                ),
                SearchTabType.OPEN_SOURCE to listOf(
                    Application(
                        name = "Open source app 1 for Firefox",
                        author = "Open author",
                        package_name = "org.example.foss1",
                        source = Source.OPEN_SOURCE,
                        status = Status.INSTALLED,
                    ),
                ),
                SearchTabType.WEB_APPS to listOf(
                    Application(
                        name = "Web app 1 for Firefox",
                        author = "Web team",
                        package_name = "org.example.pwa1",
                        source = Source.PWA,
                        status = Status.UPDATABLE,
                    ),
                    Application(
                        name = "Web app 2 for Firefox",
                        author = "Web team",
                        package_name = "org.example.pwa2",
                        source = Source.PWA,
                        status = Status.DOWNLOADING,
                    ),
                ),
            ),
            onTabSelected = {},
        )
+40 −3
Original line number Diff line number Diff line
@@ -32,6 +32,10 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.ui.compose.components.SearchEmptyState
import foundation.e.apps.ui.compose.components.SearchResultsContent
import foundation.e.apps.ui.compose.components.SearchSuggestionsDropdown
@@ -49,6 +53,10 @@ fun SearchScreen(
    onSubmitSearch: (String) -> Unit,
    onSuggestionSelected: (String) -> Unit,
    onTabSelected: (SearchTabType) -> Unit,
    onResultClick: (Application) -> Unit = {},
    onPrimaryActionClick: (Application) -> Unit = {},
    onShowMoreClick: (Application) -> Unit = {},
    onPrivacyClick: (Application) -> Unit = {},
) {
    val focusManager = LocalFocusManager.current
    val focusRequester = remember { FocusRequester() }
@@ -84,6 +92,10 @@ fun SearchScreen(
                    selectedTab = state.selectedTab!!,
                    resultsByTab = state.resultsByTab,
                    onTabSelected = onTabSelected,
                    onResultClick = onResultClick,
                    onPrimaryActionClick = onPrimaryActionClick,
                    onShowMoreClick = onShowMoreClick,
                    onPrivacyClick = onPrivacyClick,
                    modifier = Modifier
                        .align(Alignment.TopStart)
                        .fillMaxWidth()
@@ -131,9 +143,34 @@ private fun SearchScreenPreview() {
                ),
                selectedTab = SearchTabType.STANDARD_APPS,
                resultsByTab = mapOf(
                    SearchTabType.STANDARD_APPS to listOf("Standard app 1 for Telegram"),
                    SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Telegram"),
                    SearchTabType.WEB_APPS to listOf("Web app 1 for Telegram"),
                    SearchTabType.STANDARD_APPS to listOf(
                        Application(
                            name = "Standard app 1 for Telegram",
                            author = "Author 1",
                            package_name = "com.example.telegram1",
                            ratings = Ratings(usageQualityScore = 4.3),
                            source = Source.PLAY_STORE,
                            status = Status.UNAVAILABLE,
                        ),
                    ),
                    SearchTabType.OPEN_SOURCE to listOf(
                        Application(
                            name = "Open source app 1 for Telegram",
                            author = "FOSS author",
                            package_name = "org.example.foss.telegram1",
                            source = Source.OPEN_SOURCE,
                            status = Status.INSTALLED,
                        ),
                    ),
                    SearchTabType.WEB_APPS to listOf(
                        Application(
                            name = "Web app 1 for Telegram",
                            author = "PWA author",
                            package_name = "org.example.pwa.telegram1",
                            source = Source.PWA,
                            status = Status.UPDATABLE,
                        ),
                    ),
                ),
                hasSubmittedSearch = true,
            ),
+42 −9
Original line number Diff line number Diff line
@@ -22,9 +22,13 @@ import android.content.SharedPreferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_PWA
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.data.preference.AppLoungePreference
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -36,7 +40,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject

private const val SUGGESTION_DEBOUNCE_MS = 200L
private const val FAKE_RESULTS_PER_TAB = 6
private const val FAKE_RESULTS_PER_TAB = 50

enum class SearchTabType {
    STANDARD_APPS,
@@ -50,7 +54,7 @@ data class SearchUiState(
    val isSuggestionVisible: Boolean = false,
    val availableTabs: List<SearchTabType> = emptyList(),
    val selectedTab: SearchTabType? = null,
    val resultsByTab: Map<SearchTabType, List<String>> = emptyMap(),
    val resultsByTab: Map<SearchTabType, List<Application>> = emptyMap(),
    val hasSubmittedSearch: Boolean = false,
)

@@ -244,8 +248,8 @@ class SearchViewModelV2 @Inject constructor(
    private fun buildResultsForTabs(
        query: String,
        visibleTabs: List<SearchTabType>,
        existing: Map<SearchTabType, List<String>>,
    ): Map<SearchTabType, List<String>> {
        existing: Map<SearchTabType, List<Application>>,
    ): Map<SearchTabType, List<Application>> {
        if (query.isBlank()) return emptyMap()

        return buildMap {
@@ -256,14 +260,37 @@ class SearchViewModelV2 @Inject constructor(
        }
    }

    private fun generateFakeResultsFor(tab: SearchTabType, query: String): List<String> {
    private fun generateFakeResultsFor(tab: SearchTabType, query: String): List<Application> {
        val displayQuery = query.ifBlank { "Result" }
        val source = when (tab) {
            SearchTabType.STANDARD_APPS -> Source.PLAY_STORE
            SearchTabType.OPEN_SOURCE -> Source.OPEN_SOURCE
            SearchTabType.WEB_APPS -> Source.PWA
        }

        return (1..FAKE_RESULTS_PER_TAB).map { index ->
            when (tab) {
                SearchTabType.STANDARD_APPS -> "Standard app $index for $displayQuery"
                SearchTabType.OPEN_SOURCE -> "Open source app $index for $displayQuery"
                SearchTabType.WEB_APPS -> "Web app $index for $displayQuery"
            val packageName = when (tab) {
                SearchTabType.STANDARD_APPS -> "com.example.standard.$index"
                SearchTabType.OPEN_SOURCE -> "org.example.foss.$index"
                SearchTabType.WEB_APPS -> "org.example.pwa.$index"
            }

            Application(
                _id = "$tab-$index",
                name = "${tab.toReadable()} $index for $displayQuery",
                author = "Author $index",
                package_name = packageName,
                source = source,
                ratings = Ratings(usageQualityScore = 4.0 + (index % 3) * 0.1),
                is_pwa = tab == SearchTabType.WEB_APPS,
                status = when (index % 4) {
                    0 -> Status.UNAVAILABLE
                    1 -> Status.UPDATABLE
                    2 -> Status.INSTALLED
                    else -> Status.DOWNLOADING
                },
                price = if (index % 5 == 0) "$1.$index" else "",
            )
        }
    }

@@ -275,3 +302,9 @@ class SearchViewModelV2 @Inject constructor(
        )
    }
}

private fun SearchTabType.toReadable(): String = when (this) {
    SearchTabType.STANDARD_APPS -> "Standard app"
    SearchTabType.OPEN_SOURCE -> "Open source app"
    SearchTabType.WEB_APPS -> "Web app"
}