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

Commit 8e8bc5cf authored by Fahim M. Choudhury's avatar Fahim M. Choudhury
Browse files

Merge branch '3683-add-ui-components' into 'main'

feat: implement UI components for search v2

See merge request !668
parents 40c07f71 bea00621
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -317,6 +317,7 @@ dependencies {

    // Coil and PhotoView
    implementation(libs.coil)
    implementation(libs.coil.compose)
    implementation(libs.photoview)

    // Protobuf and Gson
+298 −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.components

import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.ui.compose.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SearchResultListItemTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun placeholderState_showsLoadingOnly() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Placeholder App"),
                        uiState = placeholderState(),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PLACEHOLDER)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("Placeholder App")
            .assertCountEquals(0)
    }

    @Test
    fun metadataAndClicks_areWiredCorrectly() {
        var itemClicks = 0
        var primaryClicks = 0
        var privacyClicks = 0

        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Signal"),
                        uiState = defaultState(
                            author = "Signal LLC",
                            ratingText = "4.5",
                            showRating = true,
                            sourceTag = "Play Store",
                            showSourceTag = true,
                            privacyScore = "06/10",
                            showPrivacyScore = true,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
                                isInProgress = false,
                                isFilledStyle = false,
                            ),
                        ),
                        onItemClick = { itemClicks += 1 },
                        onPrimaryActionClick = { primaryClicks += 1 },
                        onShowMoreClick = {},
                        onPrivacyClick = { privacyClicks += 1 },
                    )
                }
            }
        }

        composeRule.onNodeWithText("Signal").assertIsDisplayed()
        composeRule.onNodeWithText("Signal LLC").assertIsDisplayed()
        composeRule.onNodeWithText("4.5").assertIsDisplayed()
        composeRule.onNodeWithText("Play Store").assertIsDisplayed()
        composeRule.onNodeWithText("Install").assertIsDisplayed()
        composeRule.onNodeWithText("06/10").assertIsDisplayed()
        composeRule.onAllNodesWithTag(SearchResultListItemTestTags.SHOW_MORE)
            .assertCountEquals(0)

        composeRule.onNodeWithTag(SearchResultListItemTestTags.ROOT)
            .performClick()
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_BUTTON)
            .performClick()
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIVACY_BADGE)
            .performClick()

        composeRule.runOnIdle {
            assertEquals(1, itemClicks)
            assertEquals(1, primaryClicks)
            assertEquals(1, privacyClicks)
        }
    }

    @Test
    fun hidesRatingAndSourceTag_whenDisabled() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("No Rating"),
                        uiState = defaultState(
                            author = "Anonymous",
                            ratingText = "4.9",
                            showRating = false,
                            sourceTag = "Play Store",
                            showSourceTag = false,
                            privacyScore = "",
                            showPrivacyScore = false,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
                                isInProgress = false,
                                isFilledStyle = true,
                            ),
                        ),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onAllNodesWithText("4.9").assertCountEquals(0)
        composeRule.onAllNodesWithText("Play Store").assertCountEquals(0)
    }

    @Test
    fun showMore_replacesPrimaryButton_andFiresCallback() {
        var showMoreClicks = 0
        val showMoreLabel = composeRule.activity.getString(R.string.show_more)

        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Show More App"),
                        uiState = defaultState(
                            author = "Author",
                            ratingText = "",
                            showRating = false,
                            sourceTag = "",
                            showSourceTag = false,
                            privacyScore = "",
                            showPrivacyScore = false,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
                                isInProgress = false,
                                isFilledStyle = true,
                                showMore = true,
                            ),
                        ),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = { showMoreClicks += 1 },
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onNodeWithText(showMoreLabel)
            .assertIsDisplayed()
        composeRule.onNodeWithTag(SearchResultListItemTestTags.SHOW_MORE)
            .performClick()
        composeRule.onAllNodesWithTag(SearchResultListItemTestTags.PRIMARY_BUTTON)
            .assertCountEquals(0)

        composeRule.runOnIdle {
            assertEquals(1, showMoreClicks)
        }
    }

    @Test
    fun inProgressPrimaryAction_andPrivacyLoading_showSpinners() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultListItem(
                        application = sampleApp("Progress App"),
                        uiState = defaultState(
                            author = "Author",
                            ratingText = "",
                            showRating = false,
                            sourceTag = "",
                            showSourceTag = false,
                            privacyScore = "07/10",
                            showPrivacyScore = true,
                            isPrivacyLoading = true,
                            primaryAction = PrimaryActionUiState(
                                label = "Download",
                                enabled = true,
                                isInProgress = true,
                                isFilledStyle = true,
                            ),
                        ),
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIMARY_PROGRESS)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("Download").assertCountEquals(0)
        composeRule.onNodeWithTag(SearchResultListItemTestTags.PRIVACY_PROGRESS)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("07/10").assertCountEquals(0)
    }

    private fun sampleApp(name: String) = Application(name = name)

    private fun placeholderState(): SearchResultListItemState = 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,
    )

    private fun defaultState(
        author: String,
        ratingText: String,
        showRating: Boolean,
        sourceTag: String,
        showSourceTag: Boolean,
        privacyScore: String,
        showPrivacyScore: Boolean,
        isPrivacyLoading: Boolean,
        primaryAction: PrimaryActionUiState,
    ): SearchResultListItemState = SearchResultListItemState(
        author = author,
        ratingText = ratingText,
        showRating = showRating,
        sourceTag = sourceTag,
        showSourceTag = showSourceTag,
        privacyScore = privacyScore,
        showPrivacyScore = showPrivacyScore,
        isPrivacyLoading = isPrivacyLoading,
        primaryAction = primaryAction,
        iconUrl = null,
        placeholderResId = null,
        isPlaceholder = false,
    )
}
+191 −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.components

import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
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 org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SearchResultsContentTest {
    @get:Rule
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun emptyTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = emptyList(),
                        selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Hidden App"))
                        ),
                        onTabSelect = {},
                    )
                }
            }
        }

        composeRule.onAllNodesWithText("Hidden App")
            .assertCountEquals(0)
    }

    @Test
    fun selectedTabOutsideTabs_renderNothing() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.OPEN_SOURCE),
                        selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Missing Tab App"))
                        ),
                        onTabSelect = {},
                    )
                }
            }
        }

        composeRule.onAllNodesWithText("Missing Tab App")
            .assertCountEquals(0)
    }

    @Test
    fun tabSelection_updatesDisplayedResults() {
        val selectedTabs = mutableListOf<SearchTabType>()
        val openSourceLabel = composeRule.activity.getString(R.string.search_tab_open_source)

        composeRule.setContent {
            var selectedTab by remember { mutableStateOf(SearchTabType.COMMON_APPS) }
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE),
                        selectedTab = selectedTab,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(sampleApp("Common App")),
                            SearchTabType.OPEN_SOURCE to listOf(sampleApp("Open App")),
                        ),
                        onTabSelect = { tab ->
                            selectedTab = tab
                            selectedTabs.add(tab)
                        },
                    )
                }
            }
        }

        composeRule.onNodeWithText("Common App")
            .assertIsDisplayed()
        composeRule.onNodeWithText(openSourceLabel)
            .performClick()

        composeRule.waitForIdle()

        composeRule.onNodeWithText("Open App")
            .assertIsDisplayed()
        composeRule.runOnIdle {
            assertTrue(selectedTabs.contains(SearchTabType.OPEN_SOURCE))
        }
    }

    @Test
    fun applicationMapping_setsAuthorRatingAndPrimaryAction() {
        val notAvailable = composeRule.activity.getString(R.string.not_available)
        val openLabel = composeRule.activity.getString(R.string.open)

        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchResultsContent(
                        tabs = listOf(SearchTabType.COMMON_APPS),
                        selectedTab = SearchTabType.COMMON_APPS,
                        resultsByTab = mapOf(
                            SearchTabType.COMMON_APPS to listOf(
                                Application(
                                    name = "Rated App",
                                    author = "",
                                    package_name = "com.example.rated",
                                    source = Source.PLAY_STORE,
                                    ratings = Ratings(usageQualityScore = 4.4),
                                    status = Status.INSTALLED,
                                ),
                                Application(
                                    name = "Unrated App",
                                    author = "Team",
                                    package_name = "com.example.unrated",
                                    source = Source.PLAY_STORE,
                                    ratings = Ratings(usageQualityScore = -1.0),
                                    status = Status.UPDATABLE,
                                ),
                                Application(
                                    name = "Foss App",
                                    author = "Foss Team",
                                    package_name = "org.example.foss",
                                    source = Source.OPEN_SOURCE,
                                    ratings = Ratings(usageQualityScore = 4.9),
                                    status = Status.UPDATABLE,
                                ),
                            )
                        ),
                        onTabSelect = {},
                    )
                }
            }
        }

        composeRule.onNodeWithText("com.example.rated")
            .assertIsDisplayed()
        composeRule.onNodeWithText("4.4")
            .assertIsDisplayed()
        composeRule.onNodeWithText(openLabel)
            .assertIsDisplayed()
        composeRule.onNodeWithText(notAvailable)
            .assertIsDisplayed()
        composeRule.onAllNodesWithText("4.9")
            .assertCountEquals(0)
    }

    private fun sampleApp(name: String) = Application(name = name)
}
+290 −0

File added.

Preview size limit exceeded, changes collapsed.

+20 −2
Original line number Diff line number Diff line
@@ -27,6 +27,10 @@ import foundation.e.apps.data.enums.Source.PLAY_STORE
import foundation.e.apps.data.enums.Source.PWA
import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.preference.AppLoungePreference
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton

@@ -45,6 +49,9 @@ class Stores @Inject constructor(
        appLoungePreference
    )

    private val _enabledStoresFlow = MutableStateFlow(provideEnabledStores())
    val enabledStoresFlow: StateFlow<Set<Source>> = _enabledStoresFlow.asStateFlow()

    /**
     * Retrieves a map of enabled store repositories based on user preferences.
     *
@@ -59,16 +66,27 @@ class Stores @Inject constructor(

    fun getStore(source: Source): StoreRepository? = getStores()[source]

    fun enableStore(source: Source) =
    fun enableStore(source: Source) {
        storeConfigs[source]?.enable?.invoke()
            ?: error("No matching Store found for $source.")

    fun disableStore(source: Source) =
        _enabledStoresFlow.update { provideEnabledStores() }
    }

    fun disableStore(source: Source) {
        storeConfigs[source]?.disable?.invoke()
            ?: error("No matching Store found for $source.")

        _enabledStoresFlow.update { provideEnabledStores() }
    }

    fun isStoreEnabled(source: Source): Boolean =
        storeConfigs[source]?.isEnabled?.invoke() == true

    private fun provideEnabledStores(): Set<Source> =
        storeConfigs
            .filterValues { it.isEnabled() }
            .keys
}

internal data class StoreConfig(
Loading