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

Commit a8894a07 authored by Nishith  Khanna's avatar Nishith Khanna
Browse files

Merge branch '000-main-search' into 'main'

Add error screen states and update ui of the app to have consistent look

See merge request !695
parents 114e686a adcba3c8
Loading
Loading
Loading
Loading
Loading
+2 −52
Original line number Diff line number Diff line
@@ -54,7 +54,6 @@ class SearchResultListItemTest {
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
@@ -70,7 +69,6 @@ class SearchResultListItemTest {
    fun metadataAndClicks_areWiredCorrectly() {
        var itemClicks = 0
        var primaryClicks = 0
        var privacyClicks = 0

        composeRule.setContent {
            AppTheme(darkTheme = false) {
@@ -81,11 +79,6 @@ class SearchResultListItemTest {
                            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,
@@ -96,7 +89,6 @@ class SearchResultListItemTest {
                        onItemClick = { itemClicks += 1 },
                        onPrimaryActionClick = { primaryClicks += 1 },
                        onShowMoreClick = {},
                        onPrivacyClick = { privacyClicks += 1 },
                    )
                }
            }
@@ -105,9 +97,7 @@ class SearchResultListItemTest {
        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)

@@ -115,18 +105,15 @@ class SearchResultListItemTest {
            .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() {
    fun hidesRating_whenDisabled() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
@@ -136,11 +123,6 @@ class SearchResultListItemTest {
                            author = "Anonymous",
                            ratingText = "4.9",
                            showRating = false,
                            sourceTag = "Play Store",
                            showSourceTag = false,
                            privacyScore = "",
                            showPrivacyScore = false,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
@@ -151,14 +133,12 @@ class SearchResultListItemTest {
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
        }

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

    @Test
@@ -175,11 +155,6 @@ class SearchResultListItemTest {
                            author = "Author",
                            ratingText = "",
                            showRating = false,
                            sourceTag = "",
                            showSourceTag = false,
                            privacyScore = "",
                            showPrivacyScore = false,
                            isPrivacyLoading = false,
                            primaryAction = PrimaryActionUiState(
                                label = "Install",
                                enabled = true,
@@ -191,7 +166,6 @@ class SearchResultListItemTest {
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = { showMoreClicks += 1 },
                        onPrivacyClick = {},
                    )
                }
            }
@@ -210,7 +184,7 @@ class SearchResultListItemTest {
    }

    @Test
    fun inProgressPrimaryAction_andPrivacyLoading_showSpinners() {
    fun inProgressPrimaryAction_showsSpinner() {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
@@ -220,11 +194,6 @@ class SearchResultListItemTest {
                            author = "Author",
                            ratingText = "",
                            showRating = false,
                            sourceTag = "",
                            showSourceTag = false,
                            privacyScore = "07/10",
                            showPrivacyScore = true,
                            isPrivacyLoading = true,
                            primaryAction = PrimaryActionUiState(
                                label = "",
                                enabled = true,
@@ -235,7 +204,6 @@ class SearchResultListItemTest {
                        onItemClick = {},
                        onPrimaryActionClick = {},
                        onShowMoreClick = {},
                        onPrivacyClick = {},
                    )
                }
            }
@@ -243,9 +211,6 @@ class SearchResultListItemTest {

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

    private fun sampleApp(name: String) = Application(name = name)
@@ -254,11 +219,6 @@ class SearchResultListItemTest {
        author = "",
        ratingText = "",
        showRating = false,
        sourceTag = "",
        showSourceTag = false,
        privacyScore = "",
        showPrivacyScore = false,
        isPrivacyLoading = false,
        primaryAction = PrimaryActionUiState(
            label = "",
            enabled = false,
@@ -274,21 +234,11 @@ class SearchResultListItemTest {
        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,
+2 −47
Original line number Diff line number Diff line
@@ -204,51 +204,6 @@ class SearchResultsContentTest {
        composeRule.onAllNodesWithText("Open App").assertCountEquals(0)
    }

    @Test
    fun refreshError_showsRetry() {
        val pagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(refresh = LoadState.Error(RuntimeException("boom")))
        )

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
        ).assertIsDisplayed()
    }

    @Test
    fun appendError_showsFooterRetryWithResults() {
        val pagingData = PagingData.from(
            listOf(sampleApp("Loaded App")),
            sourceLoadStates = loadStates(
                refresh = LoadState.NotLoading(endOfPaginationReached = false),
                append = LoadState.Error(RuntimeException("append boom"))
            )
        )

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
            selectedTab = SearchTabType.OPEN_SOURCE,
            fossPagingData = pagingData,
        )

        composeRule.onNodeWithText("Loaded App").assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
        ).assertIsDisplayed()
    }

    @Test
    fun emptyResults_showsPlaceholder() {
        val pagingData = PagingData.empty<Application>(
@@ -256,7 +211,7 @@ class SearchResultsContentTest {
                refresh = LoadState.NotLoading(endOfPaginationReached = true)
            )
        )
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)
        val noAppsText = composeRule.activity.getString(R.string.search_empty_results_body)

        renderSearchResults(
            tabs = listOf(SearchTabType.OPEN_SOURCE),
@@ -269,7 +224,7 @@ class SearchResultsContentTest {

    @Test
    fun emptyResults_resetOnNewQuery_showsRefreshLoading() {
        val noAppsText = composeRule.activity.getString(R.string.no_apps_found)
        val noAppsText = composeRule.activity.getString(R.string.search_empty_results_body)
        val emptyPagingData = PagingData.empty<Application>(
            sourceLoadStates = loadStates(
                refresh = LoadState.NotLoading(endOfPaginationReached = true)
+4 −10
Original line number Diff line number Diff line
@@ -41,16 +41,13 @@ class SearchErrorStateTest {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchErrorState(onRetry = {}, fullScreen = true)
                    SearchErrorState(errorTitleStringRes = R.string.search_error_title_opensource, fullScreen = true)
                }
            }
        }

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
            composeRule.activity.getString(R.string.search_error_message)
        ).assertIsDisplayed()
    }

@@ -59,16 +56,13 @@ class SearchErrorStateTest {
        composeRule.setContent {
            AppTheme(darkTheme = false) {
                Surface(color = MaterialTheme.colorScheme.background) {
                    SearchErrorState(onRetry = {}, fullScreen = false)
                    SearchErrorState(errorTitleStringRes = R.string.search_error_title_opensource, fullScreen = false)
                }
            }
        }

        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.search_error)
        ).assertIsDisplayed()
        composeRule.onNodeWithText(
            composeRule.activity.getString(R.string.retry)
            composeRule.activity.getString(R.string.search_error_message)
        ).assertIsDisplayed()
    }
}
+5 −97
Original line number Diff line number Diff line
@@ -42,6 +42,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import foundation.e.apps.R
import foundation.e.apps.ui.search.v2.SearchUiState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -53,16 +54,13 @@ class SearchTopBarTest {
    val composeRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun typingQuery_expandsSearchBar_andUpdatesQueryState() {
    fun typingQuery_updatesQueryState() {
        val recorder = SearchTopBarRecorder()
        val hintText = composeRule.activity.getString(R.string.search_hint)

        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "",
                suggestions = emptyList(),
                initialExpanded = false,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -78,21 +76,17 @@ class SearchTopBarTest {
            .performTextInput("camera")

        composeRule.runOnIdle {
            assertTrue(recorder.expandedChanges.contains(true))
            assertTrue(recorder.queryChanges.contains("camera"))
            assertEquals("camera", recorder.queryChanges.lastOrNull())
        }
    }

    @Test
    fun submitQuery_collapsesSearchBar_andClearsFocus() {
    fun submitQuery_clearsFocus_andCallsSubmit() {
        val recorder = SearchTopBarRecorder()

        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "vpn",
                suggestions = emptyList(),
                initialExpanded = true,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -103,7 +97,6 @@ class SearchTopBarTest {
        inputField.performImeAction()

        composeRule.runOnIdle {
            assertTrue(recorder.expandedChanges.contains(false))
            assertTrue(recorder.searchSubmissions.contains("vpn"))
        }

@@ -112,15 +105,12 @@ class SearchTopBarTest {
    }

    @Test
    fun clearButton_clearsQuery_keepsExpanded_andFocusesInput() {
    fun clearButton_clearsQuery_andFocusesInput() {
        val recorder = SearchTopBarRecorder()

        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "maps",
                suggestions = emptyList(),
                initialExpanded = true,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -134,7 +124,6 @@ class SearchTopBarTest {

        composeRule.runOnIdle {
            assertTrue(recorder.clearTapped)
            assertTrue(recorder.expandedChanges.contains(true))
        }

        composeRule.onNodeWithTag(SearchTopBarTestTags.INPUT_FIELD)
@@ -148,9 +137,6 @@ class SearchTopBarTest {
        composeRule.setContent {
            SearchTopBarTestContent(
                initialQuery = "news",
                suggestions = emptyList(),
                initialExpanded = true,
                showSuggestions = false,
                recorder = recorder,
            )
        }
@@ -169,75 +155,11 @@ class SearchTopBarTest {
        composeRule.onNodeWithTag(SearchTopBarTestTags.INPUT_FIELD)
            .assertIsNotFocused()
    }

    @Test
    fun suggestions_callbackFires_whenSuggestionSelected() {
        // Material3 SearchBar renders dropdown in a popup window inaccessible to standard
        // Compose test APIs. This test verifies the callback logic is wired correctly
        // by simulating a suggestion selection through the callback.
        val recorder = SearchTopBarRecorder()
        val suggestions = listOf("camera", "camera apps", "camera pro")
        var simulateSuggestionClick: ((String) -> Unit)? = null

        composeRule.setContent {
            var query by remember { mutableStateOf("cam") }
            var expanded by remember { mutableStateOf(true) }
            val focusRequester = remember { FocusRequester() }
            val focusManager = LocalFocusManager.current

            // Capture the suggestion select callback for manual invocation
            simulateSuggestionClick = { suggestion ->
                expanded = false
                focusManager.clearFocus()
                recorder.suggestionSelections.add(suggestion)
                recorder.expandedChanges.add(false)
            }

            SearchTopBar(
                uiState = SearchUiState(
                    query = query,
                    suggestions = suggestions,
                ),
                expanded = expanded,
                showSuggestions = expanded,
                focusRequester = focusRequester,
                focusManager = focusManager,
                onQueryChange = { query = it },
                onClearQuery = { query = "" },
                onSearchSubmit = {},
                onSuggestionSelect = { suggestion ->
                    expanded = false
                    focusManager.clearFocus()
                    recorder.suggestionSelections.add(suggestion)
                    recorder.expandedChanges.add(false)
                },
                onExpandedChange = { expanded = it },
                onBack = {},
                modifier = Modifier.fillMaxSize(),
            )
        }

        // Verify the SearchBar is displayed and expanded
        composeRule.onNodeWithTag(SearchTopBarTestTags.SEARCH_BAR)
            .assertIsDisplayed()

        // Simulate suggestion selection via callback
        composeRule.runOnIdle {
            simulateSuggestionClick?.invoke("camera apps")
        }

        composeRule.runOnIdle {
            assertTrue(recorder.expandedChanges.contains(false))
            assertTrue(recorder.suggestionSelections.contains("camera apps"))
        }
    }
}

private class SearchTopBarRecorder {
    val queryChanges = mutableListOf<String>()
    val searchSubmissions = mutableListOf<String>()
    val suggestionSelections = mutableListOf<String>()
    val expandedChanges = mutableListOf<Boolean>()
    var clearTapped = false
    var backTapped = false
}
@@ -245,23 +167,16 @@ private class SearchTopBarRecorder {
@Composable
private fun SearchTopBarTestContent(
    initialQuery: String,
    suggestions: List<String>,
    initialExpanded: Boolean,
    showSuggestions: Boolean,
    recorder: SearchTopBarRecorder,
) {
    var query by remember { mutableStateOf(initialQuery) }
    var expanded by remember { mutableStateOf(initialExpanded) }
    val focusRequester = remember { FocusRequester() }
    val focusManager = LocalFocusManager.current

    SearchTopBar(
        uiState = SearchUiState(
            query = query,
            suggestions = suggestions,
        ),
        expanded = expanded,
        showSuggestions = showSuggestions && expanded,
        focusRequester = focusRequester,
        focusManager = focusManager,
        onQueryChange = { updatedQuery ->
@@ -275,13 +190,6 @@ private fun SearchTopBarTestContent(
        onSearchSubmit = { submittedQuery ->
            recorder.searchSubmissions.add(submittedQuery)
        },
        onSuggestionSelect = { suggestion ->
            recorder.suggestionSelections.add(suggestion)
        },
        onExpandedChange = { isExpanded ->
            expanded = isExpanded
            recorder.expandedChanges.add(isExpanded)
        },
        onBack = {
            recorder.backTapped = true
        },
+9 −9
Original line number Diff line number Diff line
@@ -20,7 +20,6 @@ package foundation.e.apps.ui.application

import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.text.Html
@@ -85,6 +84,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
import foundation.e.elib.R as eR

@AndroidEntryPoint
class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) {
@@ -868,8 +868,8 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) {
    private fun MaterialButton.disableInstallButton(buttonStringID: Int) {
        isEnabled = false
        text = context.getString(buttonStringID)
        strokeColor = ContextCompat.getColorStateList(context, R.color.light_grey)
        setTextColor(context.getColor(R.color.light_grey))
        strokeColor = ContextCompat.getColorStateList(context, eR.color.e_disabled_color)
        setTextColor(context.getColor(eR.color.e_disabled_color))
        backgroundTintList =
            ContextCompat.getColorStateList(context, android.R.color.transparent)
    }
@@ -877,8 +877,8 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) {
    private fun MaterialButton.enableInstallButton(buttonStringID: Int) {
        isEnabled = true
        text = context.getString(buttonStringID)
        strokeColor = ContextCompat.getColorStateList(context, R.color.colorAccent)
        setTextColor(context.getColor(R.color.colorAccent))
        strokeColor = ContextCompat.getColorStateList(context, eR.color.e_accent)
        setTextColor(context.getColor(eR.color.e_accent))
        backgroundTintList =
            ContextCompat.getColorStateList(context, android.R.color.transparent)
    }
@@ -914,9 +914,9 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) {
            } else {
                getString(R.string.update)
            }
            setTextColor(Color.WHITE)
            setTextColor(ContextCompat.getColor(view.context, eR.color.e_background))
            backgroundTintList =
                ContextCompat.getColorStateList(view.context, R.color.colorAccent)
                ContextCompat.getColorStateList(view.context, eR.color.e_accent)
            setOnClickListener {
                if (mainActivityViewModel.checkUnsupportedApplication(application, activity)) {
                    return@setOnClickListener
@@ -941,9 +941,9 @@ class ApplicationFragment : TimeoutFragment(R.layout.fragment_application) {
        appSize.visibility = View.VISIBLE
        installButton.apply {
            enableInstallButton(R.string.open)
            setTextColor(Color.WHITE)
            setTextColor(ContextCompat.getColor(view.context, eR.color.e_background))
            backgroundTintList =
                ContextCompat.getColorStateList(view.context, R.color.colorAccent)
                ContextCompat.getColorStateList(view.context, eR.color.e_accent)
            setOnClickListener {
                if (application.is_pwa) {
                    pwaManager.launchPwa(application)
Loading