From 7851c5fc3308ee9dd782d062d36df95869a0acc3 Mon Sep 17 00:00:00 2001 From: Fahim Masud Choudhury Date: Thu, 5 Feb 2026 12:24:46 +0600 Subject: [PATCH 1/7] refactor(ui): align search tabs color and text style --- .../e/apps/ui/compose/components/SearchTabs.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt index a9d810c47..cef3cc0d6 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt @@ -45,13 +45,14 @@ fun SearchTabs( modifier: Modifier = Modifier, ) { SecondaryTabRow( - modifier = modifier, + modifier = modifier.padding(horizontal = 16.dp), selectedTabIndex = selectedIndex, indicator = { if (selectedIndex in tabs.indices) { SecondaryIndicator( modifier = Modifier.tabIndicatorOffset(selectedIndex), - color = MaterialTheme.colorScheme.primary, + height = 2.dp, + color = MaterialTheme.colorScheme.tertiary, ) } }, @@ -64,14 +65,14 @@ fun SearchTabs( Tab( selected = index == selectedIndex, onClick = { onTabSelect(tab, index) }, - selectedContentColor = MaterialTheme.colorScheme.primary, - unselectedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.64f), + selectedContentColor = MaterialTheme.colorScheme.tertiary, + unselectedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.60f), ) { Text( text = label, modifier = Modifier.padding(vertical = 12.dp), style = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Medium, letterSpacing = 0.4.sp, ), ) -- GitLab From b994ce96e3590a273a21b37f8ef33a782e1cb6ee Mon Sep 17 00:00:00 2001 From: Saalim Quadri Date: Mon, 9 Feb 2026 14:17:07 +0530 Subject: [PATCH 2/7] feat: Set correct colors in TopSeachBar Signed-off-by: Saalim Quadri --- .../e/apps/ui/compose/screens/SearchScreen.kt | 45 ++++++++++--------- .../e/apps/ui/compose/screens/SearchTopBar.kt | 25 ++++++----- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index 38cefd2b2..b2cb75ac2 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -108,26 +110,29 @@ fun SearchScreen( Scaffold( modifier = modifier, topBar = { - SearchTopBar( - uiState = uiState, - expanded = isSearchExpanded, - showSuggestions = showSuggestions, - focusRequester = focusRequester, - focusManager = focusManager, - onQueryChange = onQueryChange, - onClearQuery = onClearQuery, - onSearchSubmit = onSubmitSearch, - onSuggestionSelect = onSuggestionSelect, - onExpandedChange = { expanded -> - isSearchExpanded = expanded - if (expanded) { - focusRequester.requestFocus() - } else { - focusManager.clearFocus() - } - }, - onBack = onBackClick, - ) + Column { + SearchTopBar( + uiState = uiState, + expanded = isSearchExpanded, + showSuggestions = showSuggestions, + focusRequester = focusRequester, + focusManager = focusManager, + onQueryChange = onQueryChange, + onClearQuery = onClearQuery, + onSearchSubmit = onSubmitSearch, + onSuggestionSelect = onSuggestionSelect, + onExpandedChange = { expanded -> + isSearchExpanded = expanded + if (expanded) { + focusRequester.requestFocus() + } else { + focusManager.clearFocus() + } + }, + onBack = onBackClick, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.tertiary) + } }, ) { innerPadding -> Column( diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt index 458de70f5..4f5285d47 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt @@ -21,12 +21,12 @@ package foundation.e.apps.ui.compose.screens import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,7 +35,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,6 +44,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -75,9 +75,12 @@ fun SearchTopBar( modifier = modifier .fillMaxWidth() .testTag(SearchTopBarTestTags.SEARCH_BAR), + shape = RectangleShape, + shadowElevation = 0.dp, + tonalElevation = 0.dp, colors = SearchBarDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + containerColor = MaterialTheme.colorScheme.surface, + dividerColor = MaterialTheme.colorScheme.tertiary, ), expanded = expanded, onExpandedChange = { isExpanded -> @@ -103,6 +106,11 @@ fun SearchTopBar( onExpandedChange = { isExpanded -> onExpandedChange(isExpanded) }, + colors = SearchBarDefaults.inputFieldColors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + cursorColor = MaterialTheme.colorScheme.tertiary, + ), placeholder = { Text(text = stringResource(id = R.string.search_hint)) }, leadingIcon = { IconButton( @@ -115,6 +123,7 @@ fun SearchTopBar( Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.search_back_button), + modifier = Modifier.size(28.dp), ) } }, @@ -148,7 +157,7 @@ fun SearchTopBar( }, modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), + .background(MaterialTheme.colorScheme.surface), ) } } @@ -174,11 +183,7 @@ private fun SuggestionList( .testTag("${SearchTopBarTestTags.SUGGESTION_ITEM_PREFIX}$index"), headlineContent = { Text(text = suggestion) }, leadingContent = { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(id = R.string.menu_search), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // No-op but we like the space :) }, ) } -- GitLab From 5f466d4cd123366f10fab0b8e192210f1f2152f8 Mon Sep 17 00:00:00 2001 From: Saalim Quadri Date: Mon, 9 Feb 2026 14:35:14 +0530 Subject: [PATCH 3/7] feat: Show SearchResults in main content area Signed-off-by: Saalim Quadri --- .../ui/compose/screens/SearchTopBarTest.kt | 102 +-------------- .../compose/components/SearchInitialState.kt | 4 +- .../e/apps/ui/compose/screens/SearchScreen.kt | 117 +++++++++++------- .../e/apps/ui/compose/screens/SearchTopBar.kt | 93 ++------------ 4 files changed, 92 insertions(+), 224 deletions(-) diff --git a/app/src/androidTest/java/foundation/e/apps/ui/compose/screens/SearchTopBarTest.kt b/app/src/androidTest/java/foundation/e/apps/ui/compose/screens/SearchTopBarTest.kt index d6bc044b4..d1a35e2f2 100644 --- a/app/src/androidTest/java/foundation/e/apps/ui/compose/screens/SearchTopBarTest.kt +++ b/app/src/androidTest/java/foundation/e/apps/ui/compose/screens/SearchTopBarTest.kt @@ -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() @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() val searchSubmissions = mutableListOf() - val suggestionSelections = mutableListOf() - val expandedChanges = mutableListOf() var clearTapped = false var backTapped = false } @@ -245,23 +167,16 @@ private class SearchTopBarRecorder { @Composable private fun SearchTopBarTestContent( initialQuery: String, - suggestions: List, - 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 }, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt index be1e7a536..e01c1329d 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchInitialState.kt @@ -51,7 +51,7 @@ fun SearchInitialState(modifier: Modifier = Modifier) { Icon( imageVector = Icons.Outlined.Search, contentDescription = null, - tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), modifier = Modifier .padding(bottom = 4.dp) .size(72.dp), @@ -59,7 +59,7 @@ fun SearchInitialState(modifier: Modifier = Modifier) { Text( text = stringResource(id = R.string.search_hint), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f), ) } } diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt index b2cb75ac2..5b74ad295 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchScreen.kt @@ -18,13 +18,19 @@ package foundation.e.apps.ui.compose.screens +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column 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.itemsIndexed import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -80,9 +86,7 @@ fun SearchScreen( val lifecycleOwner = LocalLifecycleOwner.current val focusRequester = remember { FocusRequester() } val shouldAutoFocus = !uiState.hasSubmittedSearch - var isSearchExpanded by rememberSaveable { mutableStateOf(shouldAutoFocus) } var hasRequestedInitialFocus by rememberSaveable { mutableStateOf(false) } - val showSuggestions = isSearchExpanded && uiState.isSuggestionVisible LaunchedEffect(lifecycleOwner, shouldAutoFocus) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { @@ -92,18 +96,13 @@ fun SearchScreen( focusRequester.requestFocus() keyboardController?.show() } - } else { - // Intentionally no-op; the initial focus request has already happened. } } } LaunchedEffect(uiState.hasSubmittedSearch) { if (uiState.hasSubmittedSearch) { - isSearchExpanded = false focusManager.clearFocus() - } else { - // Intentionally no-op; results are not active so focus remains unchanged. } } @@ -113,25 +112,17 @@ fun SearchScreen( Column { SearchTopBar( uiState = uiState, - expanded = isSearchExpanded, - showSuggestions = showSuggestions, focusRequester = focusRequester, focusManager = focusManager, onQueryChange = onQueryChange, onClearQuery = onClearQuery, onSearchSubmit = onSubmitSearch, - onSuggestionSelect = onSuggestionSelect, - onExpandedChange = { expanded -> - isSearchExpanded = expanded - if (expanded) { - focusRequester.requestFocus() - } else { - focusManager.clearFocus() - } - }, onBack = onBackClick, ) - HorizontalDivider(color = MaterialTheme.colorScheme.tertiary) + HorizontalDivider( + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 8.dp), + ) } }, ) { innerPadding -> @@ -146,33 +137,71 @@ fun SearchScreen( val shouldShowResults = uiState.hasSubmittedSearch && uiState.selectedTab != null && uiState.availableTabs.isNotEmpty() + val shouldShowSuggestions = + !uiState.hasSubmittedSearch && uiState.isSuggestionVisible && uiState.suggestions.isNotEmpty() - if (shouldShowResults) { - SearchResultsContent( - tabs = uiState.availableTabs, - selectedTab = uiState.selectedTab!!, - fossItems = fossItems, - pwaItems = pwaItems, - playStoreItems = playStoreItems, - searchVersion = searchVersion, - getScrollPosition = getScrollPosition, - onScrollPositionChange = onScrollPositionChange, - onTabSelect = onTabSelect, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - onResultClick = onResultClick, - onPrimaryActionClick = onPrimaryAction, - onShowMoreClick = onShowMoreClick, - onPrivacyClick = onPrivacyClick, - installButtonStateProvider = installButtonStateProvider, - ) - } else { - SearchInitialState( - modifier = Modifier - .fillMaxWidth(), - ) + when { + shouldShowResults -> { + SearchResultsContent( + tabs = uiState.availableTabs, + selectedTab = uiState.selectedTab!!, + fossItems = fossItems, + pwaItems = pwaItems, + playStoreItems = playStoreItems, + searchVersion = searchVersion, + getScrollPosition = getScrollPosition, + onScrollPositionChange = onScrollPositionChange, + onTabSelect = onTabSelect, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + onResultClick = onResultClick, + onPrimaryActionClick = onPrimaryAction, + onShowMoreClick = onShowMoreClick, + onPrivacyClick = onPrivacyClick, + installButtonStateProvider = installButtonStateProvider, + ) + } + shouldShowSuggestions -> { + SuggestionList( + suggestions = uiState.suggestions, + onSuggestionSelect = { suggestion -> + focusManager.clearFocus() + onSuggestionSelect(suggestion) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + else -> { + SearchInitialState( + modifier = Modifier.fillMaxSize(), + ) + } } } } } + +@Composable +private fun SuggestionList( + suggestions: List, + onSuggestionSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + itemsIndexed( + items = suggestions, + key = { _, suggestion -> suggestion }, + ) { _, suggestion -> + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onSuggestionSelect(suggestion) }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + headlineContent = { Text(text = suggestion) }, + ) + } + } +} diff --git a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt index 4f5285d47..4b9baea6b 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/screens/SearchTopBar.kt @@ -18,28 +18,20 @@ package foundation.e.apps.ui.compose.screens -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester @@ -59,15 +51,11 @@ import foundation.e.apps.ui.search.v2.SearchUiState @Composable fun SearchTopBar( uiState: SearchUiState, - expanded: Boolean, - showSuggestions: Boolean, focusRequester: FocusRequester, focusManager: FocusManager, onQueryChange: (String) -> Unit, onClearQuery: () -> Unit, onSearchSubmit: (String) -> Unit, - onSuggestionSelect: (String) -> Unit, - onExpandedChange: (Boolean) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -79,13 +67,11 @@ fun SearchTopBar( shadowElevation = 0.dp, tonalElevation = 0.dp, colors = SearchBarDefaults.colors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = MaterialTheme.colorScheme.background, dividerColor = MaterialTheme.colorScheme.tertiary, ), - expanded = expanded, - onExpandedChange = { isExpanded -> - onExpandedChange(isExpanded) - }, + expanded = false, + onExpandedChange = {}, inputField = { SearchBarDefaults.InputField( modifier = Modifier @@ -93,22 +79,16 @@ fun SearchTopBar( .focusRequester(focusRequester) .testTag(SearchTopBarTestTags.INPUT_FIELD), query = uiState.query, - onQueryChange = { query -> - onExpandedChange(true) - onQueryChange(query) - }, + onQueryChange = onQueryChange, onSearch = { query -> - onExpandedChange(false) focusManager.clearFocus() onSearchSubmit(query) }, - expanded = expanded, - onExpandedChange = { isExpanded -> - onExpandedChange(isExpanded) - }, + expanded = false, + onExpandedChange = {}, colors = SearchBarDefaults.inputFieldColors( - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + focusedContainerColor = MaterialTheme.colorScheme.background, cursorColor = MaterialTheme.colorScheme.tertiary, ), placeholder = { Text(text = stringResource(id = R.string.search_hint)) }, @@ -133,7 +113,6 @@ fun SearchTopBar( modifier = Modifier.testTag(SearchTopBarTestTags.CLEAR_BUTTON), onClick = { onClearQuery() - onExpandedChange(true) focusRequester.requestFocus() }, ) { @@ -146,48 +125,7 @@ fun SearchTopBar( }, ) }, - ) { - if (showSuggestions) { - SuggestionList( - suggestions = uiState.suggestions, - onSuggestionSelect = { suggestion -> - onExpandedChange(false) - focusManager.clearFocus() - onSuggestionSelect(suggestion) - }, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface), - ) - } - } -} - -@Composable -private fun SuggestionList( - suggestions: List, - onSuggestionSelect: (String) -> Unit, - modifier: Modifier = Modifier, -) { - LazyColumn( - modifier = modifier.testTag(SearchTopBarTestTags.SUGGESTIONS_LIST), - ) { - itemsIndexed( - items = suggestions, - key = { _, suggestion -> suggestion }, - ) { index, suggestion -> - ListItem( - modifier = Modifier - .fillMaxWidth() - .clickable { onSuggestionSelect(suggestion) } - .testTag("${SearchTopBarTestTags.SUGGESTION_ITEM_PREFIX}$index"), - headlineContent = { Text(text = suggestion) }, - leadingContent = { - // No-op but we like the space :) - }, - ) - } - } + ) {} } internal object SearchTopBarTestTags { @@ -195,8 +133,6 @@ internal object SearchTopBarTestTags { const val INPUT_FIELD = "search_top_bar_input_field" const val BACK_BUTTON = "search_top_bar_back_button" const val CLEAR_BUTTON = "search_top_bar_clear_button" - const val SUGGESTIONS_LIST = "search_top_bar_suggestions_list" - const val SUGGESTION_ITEM_PREFIX = "search_top_bar_suggestion_" } @Preview(showBackground = true) @@ -205,7 +141,6 @@ private fun SearchTopBarPreview() { AppTheme(darkTheme = true) { val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current - var expanded by remember { mutableStateOf(true) } val sampleState = SearchUiState( query = "browser", suggestions = listOf( @@ -226,15 +161,11 @@ private fun SearchTopBarPreview() { SearchTopBar( uiState = sampleState, - expanded = expanded, - showSuggestions = expanded && sampleState.suggestions.isNotEmpty(), focusRequester = focusRequester, focusManager = focusManager, - onQueryChange = { expanded = true }, - onClearQuery = { expanded = true }, - onSearchSubmit = { expanded = false }, - onSuggestionSelect = { expanded = false }, - onExpandedChange = { expanded = it }, + onQueryChange = {}, + onClearQuery = {}, + onSearchSubmit = {}, onBack = {}, ) } -- GitLab From 12e4d8adc7772a882cae9a80b1e048c9efed2933 Mon Sep 17 00:00:00 2001 From: Saalim Quadri Date: Mon, 9 Feb 2026 15:01:32 +0530 Subject: [PATCH 4/7] feat: Handle "No apps found" state Signed-off-by: Saalim Quadri --- .../compose/components/SearchPlaceholder.kt | 2 +- .../components/SearchResultsContent.kt | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt index 60c415be5..675ef702d 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchPlaceholder.kt @@ -53,7 +53,7 @@ fun SearchPlaceholder(modifier: Modifier = Modifier) { ) { Image( painter = painterResource(id = R.drawable.ic_error_circular), - contentDescription = stringResource(id = R.string.menu_search), + contentDescription = stringResource(id = R.string.no_apps_found), contentScale = ContentScale.Fit, modifier = Modifier .padding(bottom = 4.dp) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 154b4af3b..484276897 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -79,9 +79,24 @@ fun SearchResultsContent( onPrivacyClick: (Application) -> Unit = {}, installButtonStateProvider: (Application) -> InstallButtonState, ) { + if (tabs.isEmpty() || selectedTab !in tabs) { + return + } + + val fossEmpty = fossItems?.let { + it.loadState.refresh is LoadState.NotLoading && it.itemCount == 0 + } ?: true + val pwaEmpty = pwaItems?.let { + it.loadState.refresh is LoadState.NotLoading && it.itemCount == 0 + } ?: true + val playEmpty = playStoreItems?.let { + it.loadState.refresh is LoadState.NotLoading && it.itemCount == 0 + } ?: true + val allSourcesEmpty = fossEmpty && pwaEmpty && playEmpty + when { - tabs.isEmpty() || selectedTab !in tabs -> { - return + allSourcesEmpty -> { + SearchPlaceholder(modifier = modifier.fillMaxSize()) } // Don't show tabs when a single source is checked in the Settings screen @@ -101,7 +116,6 @@ fun SearchResultsContent( installButtonStateProvider = installButtonStateProvider, modifier = modifier.fillMaxSize(), ) - return } else -> { -- GitLab From 46f16ed98914ca22f522f41a7ab952644df215f3 Mon Sep 17 00:00:00 2001 From: Saalim Quadri Date: Mon, 9 Feb 2026 15:13:19 +0530 Subject: [PATCH 5/7] feat: Properly handle button pending/install states and use progressFill - Squash of following commits: https://gitlab.e.foundation/e/os/apps/-/commit/012e2fb5a193d2d5c6b3a21a0c5876e1e94c9c5d, https://gitlab.e.foundation/e/os/apps/-/commit/f545d61be06c07ead8d2036e11a4545a22baf7c7, https://gitlab.e.foundation/e/os/apps/-/commit/d4f98835c0d5216a3b8fd3e766a2e7cedff41c11, https://gitlab.e.foundation/e/os/apps/-/commit/5784a2ecba3fcb4fc6ee3b17c4ecdc882fa42a84 Signed-off-by: Saalim Quadri --- .../components/SearchResultListItem.kt | 158 ++++++++++++------ .../components/SearchResultsContent.kt | 2 +- .../ui/compose/state/InstallButtonState.kt | 1 + .../compose/state/InstallButtonStateMapper.kt | 29 +++- .../e/apps/ui/search/v2/SearchFragmentV2.kt | 78 ++++++--- .../e/apps/ui/search/v2/SearchViewModelV2.kt | 18 +- .../state/InstallButtonStateMapperTest.kt | 5 +- 7 files changed, 207 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index c764aadcf..21ee960b7 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt @@ -18,9 +18,16 @@ package foundation.e.apps.ui.compose.components -import androidx.compose.foundation.BorderStroke +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -28,14 +35,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -270,66 +277,113 @@ private fun PrimaryActionArea( .clickable(onClick = onShowMoreClick), ) return - } else { - // render the primary action button } val accentColor = MaterialTheme.colorScheme.tertiary + val hasProgress = uiState.progressFraction > 0f - val labelTextColor = when { - uiState.isFilledStyle -> MaterialTheme.colorScheme.onPrimary - else -> accentColor + val targetContainerColor = when { + hasProgress -> accentColor.copy(alpha = 0.12f) + uiState.isFilledStyle && uiState.enabled -> accentColor + uiState.isFilledStyle -> accentColor.copy(alpha = 0.12f) + else -> Color.Transparent } - val buttonContent: @Composable () -> Unit = { - val showSpinner = uiState.isInProgress && uiState.label.isBlank() - if (showSpinner) { - CircularProgressIndicator( - modifier = Modifier - .size(16.dp) - .testTag(SearchResultListItemTestTags.PRIMARY_PROGRESS), - strokeWidth = 2.dp, - color = labelTextColor, - ) - } else { - Text( - text = uiState.label, - maxLines = 1, - overflow = TextOverflow.Clip, - color = labelTextColor, - ) - } + val targetTextColor = when { + hasProgress && uiState.progressFraction >= PROGRESS_TEXT_COLOR_THRESHOLD -> Color.White + hasProgress -> accentColor + uiState.isFilledStyle -> Color.White + else -> accentColor } + val animatedContainerColor = animateColorAsState( + targetValue = targetContainerColor, + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS), + label = "containerColor" + ) + + val animatedTextColor = animateColorAsState( + targetValue = targetTextColor, + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS), + label = "textColor" + ) + + val animatedProgress = animateFloatAsState( + targetValue = uiState.progressFraction.coerceIn(0f, 1f), + animationSpec = tween(durationMillis = ANIMATION_DURATION_MS), + label = "progressFraction" + ) + 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, + val buttonShape = RoundedCornerShape(4.dp) + val showBorder = !hasProgress && !uiState.isFilledStyle + Box( modifier = Modifier + .then( + if (hasProgress) { + Modifier.width(88.dp) + } else { + Modifier.widthIn(min = 88.dp) + } + ) .height(40.dp) + .clip(buttonShape) + .background(animatedContainerColor.value) + .then( + if (showBorder) { + Modifier.border(1.dp, accentColor, buttonShape) + } else { + Modifier + } + ) + .clickable(enabled = uiState.enabled, onClick = onPrimaryClick) .testTag(SearchResultListItemTestTags.PRIMARY_BUTTON), - shape = RoundedCornerShape(4.dp), - colors = ButtonDefaults.buttonColors( - 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, borderColor), - contentPadding = ButtonDefaults.ContentPadding, + contentAlignment = Alignment.Center, ) { - buttonContent() + if (hasProgress) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(animatedProgress.value.coerceIn(0f, 1f)) + .align(Alignment.CenterStart) + .background(accentColor) + ) + } + + val showSpinner = uiState.isInProgress && uiState.label.isBlank() + val animationKey = when { + showSpinner -> "spinner" + else -> uiState.label + } + AnimatedContent( + targetState = animationKey, + transitionSpec = { + fadeIn(animationSpec = tween(ANIMATION_DURATION_MS)) togetherWith + fadeOut(animationSpec = tween(ANIMATION_DURATION_MS)) + }, + label = "buttonContent" + ) { targetKey -> + when (targetKey) { + "spinner" -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = animatedTextColor.value, + ) + } + + else -> { + Text( + text = targetKey, + maxLines = 1, + overflow = TextOverflow.Clip, + color = animatedTextColor.value, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + } } if (showPrivacyScore) { @@ -379,6 +433,7 @@ data class PrimaryActionUiState( val isFilledStyle: Boolean, val showMore: Boolean = false, val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, + val progressFraction: Float = 0f, ) internal object SearchResultListItemTestTags { @@ -391,6 +446,9 @@ internal object SearchResultListItemTestTags { const val PRIVACY_PROGRESS = "search_result_item_privacy_progress" } +private const val ANIMATION_DURATION_MS = 250 +private const val PROGRESS_TEXT_COLOR_THRESHOLD = 0.5f + // --- Previews --- @Preview(showBackground = true) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt index 484276897..6dffa442c 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt @@ -615,7 +615,6 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): isPrivacyLoading = false, primaryAction = PrimaryActionUiState( label = buttonState.label.text - ?: buttonState.progressPercentText ?: buttonState.label.resId?.let { stringResource(id = it) } ?: "", enabled = buttonState.enabled, @@ -623,6 +622,7 @@ private fun Application.toSearchResultUiState(buttonState: InstallButtonState): isFilledStyle = buttonState.style == InstallButtonStyle.AccentFill, showMore = false, actionIntent = buttonState.actionIntent, + progressFraction = buttonState.progressFraction, ), iconUrl = iconUrl, placeholderResId = null, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt index 5aa16d20b..6a9b07e3d 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonState.kt @@ -31,6 +31,7 @@ data class InstallButtonState( val style: InstallButtonStyle = InstallButtonStyle.AccentOutline, val showProgressBar: Boolean = false, val progressPercentText: String? = null, + val progressFraction: Float = 0f, val actionIntent: InstallButtonAction = InstallButtonAction.NoOp, @StringRes val snackbarMessageId: Int? = null, val dialogType: InstallDialogType? = null, diff --git a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt index b24d7f013..5df5c1117 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt @@ -32,7 +32,8 @@ fun mapAppToInstallState(input: InstallButtonStateInput): InstallButtonState { Status.INSTALLED -> mapInstalled() Status.UPDATABLE -> mapUpdatable(input) Status.UNAVAILABLE -> mapUnavailable(input) - Status.QUEUED, Status.AWAITING, Status.DOWNLOADING, Status.DOWNLOADED -> mapDownloading(input, status) + Status.QUEUED, Status.AWAITING -> mapQueued(status) + Status.DOWNLOADING, Status.DOWNLOADED -> mapDownloading(input, status) Status.INSTALLING -> mapInstalling(status) Status.BLOCKED -> mapBlocked(input) Status.INSTALLATION_ISSUE -> mapInstallationIssue(input) @@ -136,13 +137,27 @@ private fun mapUnavailablePaid(input: InstallButtonStateInput): InstallButtonSta } } +private fun mapQueued(status: Status): InstallButtonState { + return InstallButtonState( + label = ButtonLabel(resId = R.string.cancel), + progressPercentText = null, + enabled = true, + style = buildStyleFor(status, enabled = true), + actionIntent = InstallButtonAction.CancelDownload, + statusTag = StatusTag.Downloading, + rawStatus = status, + ) +} + private fun mapDownloading(input: InstallButtonStateInput, status: Status): InstallButtonState { + val fraction = input.progressPercent + ?.coerceIn(PROGRESS_MIN, PROGRESS_MAX) + ?.div(PROGRESS_MAX_FLOAT) + ?: 0f return InstallButtonState( - label = ButtonLabel( - resId = if (input.percentLabel == null) R.string.cancel else null, - text = input.percentLabel, - ), + label = ButtonLabel(resId = R.string.cancel), progressPercentText = input.percentLabel, + progressFraction = fraction, enabled = true, style = buildStyleFor(status, enabled = true), actionIntent = InstallButtonAction.CancelDownload, @@ -216,3 +231,7 @@ private fun buildStyleFor(status: Status, enabled: Boolean): InstallButtonStyle else -> InstallButtonStyle.Disabled } } + +private const val PROGRESS_MIN = 0 +private const val PROGRESS_MAX = 100 +private const val PROGRESS_MAX_FLOAT = 100f diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt index 3e472f97c..cc68ad745 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt @@ -48,9 +48,11 @@ import foundation.e.apps.ui.AppProgressViewModel import foundation.e.apps.ui.MainActivityViewModel import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment import foundation.e.apps.ui.compose.screens.SearchScreen +import foundation.e.apps.ui.compose.state.ButtonLabel import foundation.e.apps.ui.compose.state.InstallButtonAction import foundation.e.apps.ui.compose.state.InstallButtonState import foundation.e.apps.ui.compose.state.InstallButtonStateInput +import foundation.e.apps.ui.compose.state.InstallButtonStyle import foundation.e.apps.ui.compose.state.PurchaseState import foundation.e.apps.ui.compose.state.mapAppToInstallState import foundation.e.apps.ui.compose.theme.AppTheme @@ -101,16 +103,20 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { val downloadProgress by appProgressViewModel.downloadProgress.observeAsState() val progressPercentMap by searchViewModel.progressPercentByKey.collectAsState() val statusByKey by searchViewModel.statusByKey.collectAsState() + val pendingInstalls by searchViewModel.pendingInstalls.collectAsState() val selfPackageName = requireContext().packageName DownloadProgressEffect(downloadProgress) val installButtonStateProvider = buildInstallButtonStateProvider( - user = user, - isAnonymous = isAnonymous, - progressPercentMap = progressPercentMap, - statusByKey = statusByKey, - selfPackageName = selfPackageName, + InstallButtonProviderParams( + user = user, + isAnonymous = isAnonymous, + progressPercentMap = progressPercentMap, + statusByKey = statusByKey, + pendingInstalls = pendingInstalls, + selfPackageName = selfPackageName, + ) ) SearchScreen( @@ -148,33 +154,41 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { } private fun buildInstallButtonStateProvider( - user: User, - isAnonymous: Boolean, - progressPercentMap: Map, - statusByKey: Map, - selfPackageName: String, + params: InstallButtonProviderParams, ): (Application) -> InstallButtonState { return { app -> val progressKey = progressKeyFor(app) - val progressPercent = progressPercentMap[progressKey] - val overrideStatus = statusByKey[progressKey] + val progressPercent = params.progressPercentMap[progressKey] + val overrideStatus = params.statusByKey[progressKey] + val isPending = progressKey in params.pendingInstalls val purchaseState = purchaseStateFor(app) val isBlocked = appInfoFetchViewModel.isAppInBlockedList(app) val isUnsupported = isUnsupportedApp(app) - mapInstallButtonState( - app = app, - installButtonContext = InstallButtonContext( - user = user, - isAnonymous = isAnonymous, - isUnsupported = isUnsupported, - purchaseState = purchaseState, - progressPercent = progressPercent, - overrideStatus = overrideStatus, - isBlocked = isBlocked, - selfPackageName = selfPackageName, + // Show disabled state while waiting for install to register + if (isPending && (overrideStatus == null || overrideStatus == Status.UNAVAILABLE)) { + InstallButtonState( + label = ButtonLabel(), + enabled = false, + style = InstallButtonStyle.AccentOutline, + showProgressBar = true, + actionIntent = InstallButtonAction.NoOp, ) - ) + } else { + mapInstallButtonState( + app = app, + installButtonContext = InstallButtonContext( + user = params.user, + isAnonymous = params.isAnonymous, + isUnsupported = isUnsupported, + purchaseState = purchaseState, + progressPercent = progressPercent, + overrideStatus = overrideStatus, + isBlocked = isBlocked, + selfPackageName = params.selfPackageName, + ) + ) + } } } @@ -234,6 +248,15 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { val selfPackageName: String, ) + private data class InstallButtonProviderParams( + val user: User, + val isAnonymous: Boolean, + val progressPercentMap: Map, + val statusByKey: Map, + val pendingInstalls: Set, + val selfPackageName: String, + ) + private fun copyProgress(progress: DownloadProgress): DownloadProgress { return DownloadProgress( totalSizeBytes = progress.totalSizeBytes.toMutableMap(), @@ -247,15 +270,20 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) { when (action) { InstallButtonAction.Install, InstallButtonAction.UpdateSelfConfirm -> { + searchViewModel.markPendingInstall(app) mainActivityViewModel.verifyUiFilter(app) { if (mainActivityViewModel.shouldShowPaidAppsSnackBar(app)) { + searchViewModel.clearPendingInstall(app) return@verifyUiFilter } mainActivityViewModel.getApplication(app) } } - InstallButtonAction.CancelDownload -> mainActivityViewModel.cancelDownload(app) + InstallButtonAction.CancelDownload -> { + searchViewModel.clearPendingInstall(app) + mainActivityViewModel.cancelDownload(app) + } InstallButtonAction.OpenAppOrPwa -> { if (app.is_pwa) { mainActivityViewModel.launchPwa(app) diff --git a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt index b093d55ee..c209b8ae6 100644 --- a/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +++ b/app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt @@ -79,7 +79,7 @@ data class ScrollPosition( ) @HiltViewModel -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") class SearchViewModelV2 @Inject constructor( private val appLoungePreference: AppLoungePreference, cleanApkSearchPagingUseCase: CleanApkSearchPagingUseCase, @@ -110,6 +110,9 @@ class SearchViewModelV2 @Inject constructor( private val _statusByKey = MutableStateFlow>(emptyMap()) val statusByKey: StateFlow> = _statusByKey.asStateFlow() + private val _pendingInstalls = MutableStateFlow>(emptySet()) + val pendingInstalls: StateFlow> = _pendingInstalls.asStateFlow() + val fossPagingFlow = cleanApkSearchPagingUseCase( requests = searchRequests, source = Source.OPEN_SOURCE, @@ -340,6 +343,19 @@ class SearchViewModelV2 @Inject constructor( _statusByKey.update { current -> if (current[key] != app.status) current + (key to app.status) else current } + if (app.status != Status.UNAVAILABLE) { + _pendingInstalls.update { it - key } + } + } + + fun markPendingInstall(app: Application) { + val key = keyFor(app) + _pendingInstalls.update { it + key } + } + + fun clearPendingInstall(app: Application) { + val key = keyFor(app) + _pendingInstalls.update { it - key } } private fun keyFor(app: Application): String { diff --git a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt index f81b483da..d7683f9a6 100644 --- a/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapperTest.kt @@ -183,14 +183,15 @@ class InstallButtonStateMapperTest { } @Test - fun downloading_with_progress_shows_percent_and_cancel_intent() { + fun downloading_with_progress_shows_cancel_label_and_progress_fraction() { val state = mapAppToInstallState( input = defaultInput( app = baseApp(Status.DOWNLOADING), progressPercent = PROGRESS_PERCENT_VALID, ), ) - assertEquals("${PROGRESS_PERCENT_VALID}%", state.label.text) + assertEquals(R.string.cancel, state.label.resId) + assertEquals(PROGRESS_PERCENT_VALID / 100f, state.progressFraction) assertEquals(InstallButtonAction.CancelDownload, state.actionIntent) assertEquals(StatusTag.Downloading, state.statusTag) } -- GitLab From cd4675394f9e0afe97d40252d9a7d2b38a67e051 Mon Sep 17 00:00:00 2001 From: Saalim Quadri Date: Mon, 9 Feb 2026 16:12:24 +0530 Subject: [PATCH 6/7] fix: Match button text with background theme Signed-off-by: Saalim Quadri --- .../e/apps/ui/compose/components/SearchResultListItem.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt index 21ee960b7..ada1a07c5 100644 --- a/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt +++ b/app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultListItem.kt @@ -289,10 +289,11 @@ private fun PrimaryActionArea( else -> Color.Transparent } + val filledTextColor = MaterialTheme.colorScheme.background val targetTextColor = when { - hasProgress && uiState.progressFraction >= PROGRESS_TEXT_COLOR_THRESHOLD -> Color.White + hasProgress && uiState.progressFraction >= PROGRESS_TEXT_COLOR_THRESHOLD -> filledTextColor hasProgress -> accentColor - uiState.isFilledStyle -> Color.White + uiState.isFilledStyle -> filledTextColor else -> accentColor } -- GitLab From 23471a248fa3d5506a58a985f6987c711721031e Mon Sep 17 00:00:00 2001 From: Saalim Quadri Date: Mon, 9 Feb 2026 14:05:57 +0530 Subject: [PATCH 7/7] feat: Select new SearchScreen Signed-off-by: Saalim Quadri --- app/src/main/res/menu/navigation_menu.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/menu/navigation_menu.xml b/app/src/main/res/menu/navigation_menu.xml index 9b412b814..885eeaff7 100644 --- a/app/src/main/res/menu/navigation_menu.xml +++ b/app/src/main/res/menu/navigation_menu.xml @@ -30,7 +30,7 @@ android:title="@string/menu_categories" /> -- GitLab