Loading app/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt +13 −0 Original line number Diff line number Diff line Loading @@ -20,6 +20,7 @@ package foundation.e.apps.data.preference import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext Loading Loading @@ -65,6 +66,18 @@ class AppLoungePreference @Inject constructor( fun enableOpenSource() = preferenceManager.edit { putBoolean(PREFERENCE_SHOW_FOSS, true) } fun enablePwa() = preferenceManager.edit { putBoolean(PREFERENCE_SHOW_PWA, true) } /** * Expose preference change registration so UI layers can react to store toggles immediately. * Callers must unregister to avoid leaking the listener beyond the consumer lifecycle. */ fun registerStorePreferenceListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { preferenceManager.registerOnSharedPreferenceChangeListener(listener) } fun unregisterStorePreferenceListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { preferenceManager.unregisterOnSharedPreferenceChangeListener(listener) } fun getUpdateInterval(): Long { val currentUser = appLoungeDataStore.getUser() return when (currentUser) { Loading app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt 0 → 100644 +154 −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.compose.foundation.layout.Arrangement 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.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType import kotlinx.coroutines.launch @Composable fun SearchResultsContent( tabs: List<SearchTabType>, selectedTab: SearchTabType, resultsByTab: Map<SearchTabType, List<String>>, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, ) { if (tabs.isEmpty() || selectedTab !in tabs) { return } val coroutineScope = rememberCoroutineScope() val selectedIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) val pagerState = rememberPagerState( initialPage = selectedIndex, pageCount = { tabs.size }, ) val currentOnTabSelect = rememberUpdatedState(onTabSelect) val currentSelectedTab = rememberUpdatedState(selectedTab) LaunchedEffect(tabs, selectedTab) { val newIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) if (newIndex in 0 until pagerState.pageCount && pagerState.currentPage != newIndex) { pagerState.scrollToPage(newIndex) } } LaunchedEffect(pagerState.currentPage, tabs) { tabs.getOrNull(pagerState.currentPage)?.let { tab -> if (tab != currentSelectedTab.value) { currentOnTabSelect.value(tab) } } } Column( modifier = modifier.fillMaxSize(), ) { SearchTabs( tabs = tabs, selectedIndex = pagerState.currentPage, onTabSelect = { tab, index -> coroutineScope.launch { pagerState.animateScrollToPage(index) } onTabSelect(tab) }, modifier = Modifier.fillMaxWidth(), ) HorizontalPager( state = pagerState, modifier = Modifier .fillMaxSize() .padding(top = 16.dp), ) { page -> val tab = tabs[page] val items = resultsByTab[tab].orEmpty() SearchResultList( items = items, modifier = Modifier.fillMaxSize(), ) } } } @Composable private fun SearchResultList( items: List<String>, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( items = items, key = { item -> item }, ) { item -> Text( text = item, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.fillMaxWidth(), ) } } } @Preview(showBackground = true) @Composable private fun SearchResultsContentPreview() { AppTheme(darkTheme = true) { SearchResultsContent( tabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), selectedTab = SearchTabType.OPEN_SOURCE, resultsByTab = mapOf( SearchTabType.COMMON_APPS to listOf( "Standard app 1 for Firefox", "Standard app 2 for Firefox" ), SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Firefox"), SearchTabType.PWA to listOf("Web app 1 for Firefox", "Web app 2 for Firefox"), ), onTabSelect = {}, ) } } app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt 0 → 100644 +104 −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.annotation.StringRes import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults.SecondaryIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import foundation.e.apps.R import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType @Composable fun SearchTabs( tabs: List<SearchTabType>, selectedIndex: Int, onTabSelect: (SearchTabType, Int) -> Unit, modifier: Modifier = Modifier, ) { SecondaryTabRow( modifier = modifier, selectedTabIndex = selectedIndex, indicator = { if (selectedIndex in tabs.indices) { SecondaryIndicator( modifier = Modifier.tabIndicatorOffset(selectedIndex), color = MaterialTheme.colorScheme.primary, ) } }, containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, divider = { HorizontalDivider(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.08f)) }, ) { tabs.forEachIndexed { index, tab -> val label = stringResource(id = tab.toLabelRes()) Tab( selected = index == selectedIndex, onClick = { onTabSelect(tab, index) }, selectedContentColor = MaterialTheme.colorScheme.primary, unselectedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.64f), ) { Text( text = label, modifier = Modifier.padding(vertical = 12.dp), style = MaterialTheme.typography.labelLarge.copy( fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp, ), ) } } } } @Preview(showBackground = true) @Composable private fun SearchTabsPreview() { AppTheme(darkTheme = true) { SearchTabs( tabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), selectedIndex = 1, onTabSelect = { _, _ -> }, ) } } @StringRes private fun SearchTabType.toLabelRes(): Int = when (this) { SearchTabType.COMMON_APPS -> R.string.search_tab_standard_apps SearchTabType.OPEN_SOURCE -> R.string.search_tab_open_source SearchTabType.PWA -> R.string.search_tab_web_apps } app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +197 −9 Original line number Diff line number Diff line Loading @@ -18,9 +18,18 @@ package foundation.e.apps.ui.search.v2 import android.content.SharedPreferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY import foundation.e.apps.data.Constants.PREFERENCE_SHOW_PWA import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.data.search.SuggestionSource import kotlinx.coroutines.Job import kotlinx.coroutines.delay Loading @@ -32,29 +41,92 @@ import kotlinx.coroutines.launch import javax.inject.Inject private const val SUGGESTION_DEBOUNCE_MS = 200L private const val FAKE_RESULTS_PER_TAB = 6 enum class SearchTabType { COMMON_APPS, OPEN_SOURCE, PWA, } data class SearchUiState( val query: String = "", val suggestions: List<String> = emptyList(), val isSuggestionVisible: Boolean = false, val availableTabs: List<SearchTabType> = emptyList(), val selectedTab: SearchTabType? = null, val resultsByTab: Map<SearchTabType, List<String>> = emptyMap(), val hasSubmittedSearch: Boolean = false, ) @HiltViewModel class SearchViewModelV2 @Inject constructor( private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, private val stores: Stores ) : ViewModel() { private val _uiState = MutableStateFlow(SearchUiState()) private val initialVisibleTabs = resolveVisibleTabs() private val _uiState = MutableStateFlow( SearchUiState( availableTabs = initialVisibleTabs, selectedTab = initialVisibleTabs.firstOrNull(), ) ) val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow() private var suggestionJob: Job? = null private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key in STORE_PREFERENCE_KEYS) { handleStoreSelectionChanged() } } init { appLoungePreference.registerStorePreferenceListener(preferenceListener) handleStoreSelectionChanged() } override fun onCleared() { appLoungePreference.unregisterStorePreferenceListener(preferenceListener) super.onCleared() } fun onQueryChanged(newQuery: String) { _uiState.update { current -> current.copy(query = newQuery) } suggestionJob?.cancel() if (newQuery.isBlank()) { _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { // Keep existing results/tabs visible; just hide suggestions and clear query. current.copy( suggestions = emptyList(), isSuggestionVisible = false, query = "", ) } else { val visibleTabs = resolveVisibleTabs() current.copy( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), query = "", ) } } return } if (!appLoungePreference.isPlayStoreSelected()) { _uiState.update { current -> current.copy( suggestions = emptyList(), Loading @@ -63,6 +135,7 @@ class SearchViewModelV2 @Inject constructor( } return } suggestionJob = viewModelScope.launch { delay(SUGGESTION_DEBOUNCE_MS) val suggestions = suggestionSource.suggest(newQuery) Loading @@ -76,27 +149,142 @@ class SearchViewModelV2 @Inject constructor( } fun onSuggestionSelected(suggestion: String) { onSearchSubmitted(suggestion) } fun onClearQuery() { suggestionJob?.cancel() _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { current.copy( query = suggestion, query = "", suggestions = emptyList(), isSuggestionVisible = false, ) } else { val visibleTabs = resolveVisibleTabs() current.copy( query = "", suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), ) } } fun onQueryCleared() { suggestionJob?.cancel() _uiState.value = SearchUiState() } fun onSearchSubmitted(submitted: String) { val trimmedQuery = submitted.trim() if (trimmedQuery.isEmpty()) { onClearQuery() return } val visibleTabs = resolveVisibleTabs() val selectedTab = _uiState.value.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() val results = if (visibleTabs.isEmpty()) { emptyMap() } else { buildResultsForTabs(trimmedQuery, visibleTabs, emptyMap()) } _uiState.update { current -> current.copy( query = submitted, query = trimmedQuery, suggestions = emptyList(), isSuggestionVisible = false, availableTabs = visibleTabs, selectedTab = selectedTab, resultsByTab = results, hasSubmittedSearch = visibleTabs.isNotEmpty(), ) } } fun onTabSelected(tab: SearchTabType) { _uiState.update { current -> if (!current.availableTabs.contains(tab)) { current } else { current.copy(selectedTab = tab) } } } private fun handleStoreSelectionChanged() { val visibleTabs = resolveVisibleTabs() _uiState.update { current -> val selectedTab = current.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() val updatedResults = if (current.hasSubmittedSearch && visibleTabs.isNotEmpty()) { buildResultsForTabs( query = current.query, visibleTabs = visibleTabs, existing = current.resultsByTab, ) } else { emptyMap() } current.copy( availableTabs = visibleTabs, selectedTab = selectedTab, resultsByTab = updatedResults, hasSubmittedSearch = current.hasSubmittedSearch && visibleTabs.isNotEmpty(), isSuggestionVisible = current.isSuggestionVisible && appLoungePreference.isPlayStoreSelected(), ) } } private fun resolveVisibleTabs(): List<SearchTabType> = stores.getStores().mapNotNull { (key, _) -> when (key) { PLAY_STORE -> SearchTabType.COMMON_APPS OPEN_SOURCE -> SearchTabType.OPEN_SOURCE PWA -> SearchTabType.PWA else -> null } } private fun buildResultsForTabs( query: String, visibleTabs: List<SearchTabType>, existing: Map<SearchTabType, List<String>>, ): Map<SearchTabType, List<String>> { if (query.isBlank()) return emptyMap() return buildMap { visibleTabs.forEach { tab -> val preserved = existing[tab] put(tab, preserved ?: generateFakeResultsFor(tab, query)) } } } private fun generateFakeResultsFor(tab: SearchTabType, query: String): List<String> { val displayQuery = query.ifBlank { "Result" } return (1..FAKE_RESULTS_PER_TAB).map { index -> when (tab) { SearchTabType.COMMON_APPS -> "Standard app $index for $displayQuery" SearchTabType.OPEN_SOURCE -> "Open source app $index for $displayQuery" SearchTabType.PWA -> "Web app $index for $displayQuery" } } } companion object { private val STORE_PREFERENCE_KEYS = setOf( PREFERENCE_SHOW_GPLAY, PREFERENCE_SHOW_FOSS, PREFERENCE_SHOW_PWA, ) } } app/src/main/res/values/strings.xml +4 −0 Original line number Diff line number Diff line Loading @@ -26,8 +26,12 @@ <string name="menu_search" weblate_ctx="home">Search</string> <string name="menu_updates" weblate_ctx="home">Updates</string> <string name="menu_settings" weblate_ctx="home">Settings</string> <!-- Search Fragment --> <string name="search_hint" weblate_ctx="search">Search for an app</string> <string name="search_tab_standard_apps">APPS</string> <string name="search_tab_open_source">OPEN SOURCE</string> <string name="search_tab_web_apps">WEB APPS</string> <string name="no_apps_found">No apps found…</string> <!-- Categories Fragment --> Loading Loading
app/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt +13 −0 Original line number Diff line number Diff line Loading @@ -20,6 +20,7 @@ package foundation.e.apps.data.preference import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext Loading Loading @@ -65,6 +66,18 @@ class AppLoungePreference @Inject constructor( fun enableOpenSource() = preferenceManager.edit { putBoolean(PREFERENCE_SHOW_FOSS, true) } fun enablePwa() = preferenceManager.edit { putBoolean(PREFERENCE_SHOW_PWA, true) } /** * Expose preference change registration so UI layers can react to store toggles immediately. * Callers must unregister to avoid leaking the listener beyond the consumer lifecycle. */ fun registerStorePreferenceListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { preferenceManager.registerOnSharedPreferenceChangeListener(listener) } fun unregisterStorePreferenceListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { preferenceManager.unregisterOnSharedPreferenceChangeListener(listener) } fun getUpdateInterval(): Long { val currentUser = appLoungeDataStore.getUser() return when (currentUser) { Loading
app/src/main/java/foundation/e/apps/ui/compose/components/SearchResultsContent.kt 0 → 100644 +154 −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.compose.foundation.layout.Arrangement 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.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType import kotlinx.coroutines.launch @Composable fun SearchResultsContent( tabs: List<SearchTabType>, selectedTab: SearchTabType, resultsByTab: Map<SearchTabType, List<String>>, onTabSelect: (SearchTabType) -> Unit, modifier: Modifier = Modifier, ) { if (tabs.isEmpty() || selectedTab !in tabs) { return } val coroutineScope = rememberCoroutineScope() val selectedIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) val pagerState = rememberPagerState( initialPage = selectedIndex, pageCount = { tabs.size }, ) val currentOnTabSelect = rememberUpdatedState(onTabSelect) val currentSelectedTab = rememberUpdatedState(selectedTab) LaunchedEffect(tabs, selectedTab) { val newIndex = tabs.indexOf(selectedTab).coerceAtLeast(0) if (newIndex in 0 until pagerState.pageCount && pagerState.currentPage != newIndex) { pagerState.scrollToPage(newIndex) } } LaunchedEffect(pagerState.currentPage, tabs) { tabs.getOrNull(pagerState.currentPage)?.let { tab -> if (tab != currentSelectedTab.value) { currentOnTabSelect.value(tab) } } } Column( modifier = modifier.fillMaxSize(), ) { SearchTabs( tabs = tabs, selectedIndex = pagerState.currentPage, onTabSelect = { tab, index -> coroutineScope.launch { pagerState.animateScrollToPage(index) } onTabSelect(tab) }, modifier = Modifier.fillMaxWidth(), ) HorizontalPager( state = pagerState, modifier = Modifier .fillMaxSize() .padding(top = 16.dp), ) { page -> val tab = tabs[page] val items = resultsByTab[tab].orEmpty() SearchResultList( items = items, modifier = Modifier.fillMaxSize(), ) } } } @Composable private fun SearchResultList( items: List<String>, modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( items = items, key = { item -> item }, ) { item -> Text( text = item, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.fillMaxWidth(), ) } } } @Preview(showBackground = true) @Composable private fun SearchResultsContentPreview() { AppTheme(darkTheme = true) { SearchResultsContent( tabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), selectedTab = SearchTabType.OPEN_SOURCE, resultsByTab = mapOf( SearchTabType.COMMON_APPS to listOf( "Standard app 1 for Firefox", "Standard app 2 for Firefox" ), SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Firefox"), SearchTabType.PWA to listOf("Web app 1 for Firefox", "Web app 2 for Firefox"), ), onTabSelect = {}, ) } }
app/src/main/java/foundation/e/apps/ui/compose/components/SearchTabs.kt 0 → 100644 +104 −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.annotation.StringRes import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults.SecondaryIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import foundation.e.apps.R import foundation.e.apps.ui.compose.theme.AppTheme import foundation.e.apps.ui.search.v2.SearchTabType @Composable fun SearchTabs( tabs: List<SearchTabType>, selectedIndex: Int, onTabSelect: (SearchTabType, Int) -> Unit, modifier: Modifier = Modifier, ) { SecondaryTabRow( modifier = modifier, selectedTabIndex = selectedIndex, indicator = { if (selectedIndex in tabs.indices) { SecondaryIndicator( modifier = Modifier.tabIndicatorOffset(selectedIndex), color = MaterialTheme.colorScheme.primary, ) } }, containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, divider = { HorizontalDivider(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.08f)) }, ) { tabs.forEachIndexed { index, tab -> val label = stringResource(id = tab.toLabelRes()) Tab( selected = index == selectedIndex, onClick = { onTabSelect(tab, index) }, selectedContentColor = MaterialTheme.colorScheme.primary, unselectedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.64f), ) { Text( text = label, modifier = Modifier.padding(vertical = 12.dp), style = MaterialTheme.typography.labelLarge.copy( fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp, ), ) } } } } @Preview(showBackground = true) @Composable private fun SearchTabsPreview() { AppTheme(darkTheme = true) { SearchTabs( tabs = listOf( SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE, SearchTabType.PWA, ), selectedIndex = 1, onTabSelect = { _, _ -> }, ) } } @StringRes private fun SearchTabType.toLabelRes(): Int = when (this) { SearchTabType.COMMON_APPS -> R.string.search_tab_standard_apps SearchTabType.OPEN_SOURCE -> R.string.search_tab_open_source SearchTabType.PWA -> R.string.search_tab_web_apps }
app/src/main/java/foundation/e/apps/ui/search/v2/SearchViewModelV2.kt +197 −9 Original line number Diff line number Diff line Loading @@ -18,9 +18,18 @@ package foundation.e.apps.ui.search.v2 import android.content.SharedPreferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY import foundation.e.apps.data.Constants.PREFERENCE_SHOW_PWA import foundation.e.apps.data.Stores import foundation.e.apps.data.enums.Source.OPEN_SOURCE import foundation.e.apps.data.enums.Source.PLAY_STORE import foundation.e.apps.data.enums.Source.PWA import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.data.search.SuggestionSource import kotlinx.coroutines.Job import kotlinx.coroutines.delay Loading @@ -32,29 +41,92 @@ import kotlinx.coroutines.launch import javax.inject.Inject private const val SUGGESTION_DEBOUNCE_MS = 200L private const val FAKE_RESULTS_PER_TAB = 6 enum class SearchTabType { COMMON_APPS, OPEN_SOURCE, PWA, } data class SearchUiState( val query: String = "", val suggestions: List<String> = emptyList(), val isSuggestionVisible: Boolean = false, val availableTabs: List<SearchTabType> = emptyList(), val selectedTab: SearchTabType? = null, val resultsByTab: Map<SearchTabType, List<String>> = emptyMap(), val hasSubmittedSearch: Boolean = false, ) @HiltViewModel class SearchViewModelV2 @Inject constructor( private val suggestionSource: SuggestionSource, private val appLoungePreference: AppLoungePreference, private val stores: Stores ) : ViewModel() { private val _uiState = MutableStateFlow(SearchUiState()) private val initialVisibleTabs = resolveVisibleTabs() private val _uiState = MutableStateFlow( SearchUiState( availableTabs = initialVisibleTabs, selectedTab = initialVisibleTabs.firstOrNull(), ) ) val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow() private var suggestionJob: Job? = null private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key in STORE_PREFERENCE_KEYS) { handleStoreSelectionChanged() } } init { appLoungePreference.registerStorePreferenceListener(preferenceListener) handleStoreSelectionChanged() } override fun onCleared() { appLoungePreference.unregisterStorePreferenceListener(preferenceListener) super.onCleared() } fun onQueryChanged(newQuery: String) { _uiState.update { current -> current.copy(query = newQuery) } suggestionJob?.cancel() if (newQuery.isBlank()) { _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { // Keep existing results/tabs visible; just hide suggestions and clear query. current.copy( suggestions = emptyList(), isSuggestionVisible = false, query = "", ) } else { val visibleTabs = resolveVisibleTabs() current.copy( suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), query = "", ) } } return } if (!appLoungePreference.isPlayStoreSelected()) { _uiState.update { current -> current.copy( suggestions = emptyList(), Loading @@ -63,6 +135,7 @@ class SearchViewModelV2 @Inject constructor( } return } suggestionJob = viewModelScope.launch { delay(SUGGESTION_DEBOUNCE_MS) val suggestions = suggestionSource.suggest(newQuery) Loading @@ -76,27 +149,142 @@ class SearchViewModelV2 @Inject constructor( } fun onSuggestionSelected(suggestion: String) { onSearchSubmitted(suggestion) } fun onClearQuery() { suggestionJob?.cancel() _uiState.update { current -> if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) { current.copy( query = suggestion, query = "", suggestions = emptyList(), isSuggestionVisible = false, ) } else { val visibleTabs = resolveVisibleTabs() current.copy( query = "", suggestions = emptyList(), isSuggestionVisible = false, hasSubmittedSearch = false, resultsByTab = emptyMap(), availableTabs = visibleTabs, selectedTab = visibleTabs.firstOrNull(), ) } } fun onQueryCleared() { suggestionJob?.cancel() _uiState.value = SearchUiState() } fun onSearchSubmitted(submitted: String) { val trimmedQuery = submitted.trim() if (trimmedQuery.isEmpty()) { onClearQuery() return } val visibleTabs = resolveVisibleTabs() val selectedTab = _uiState.value.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() val results = if (visibleTabs.isEmpty()) { emptyMap() } else { buildResultsForTabs(trimmedQuery, visibleTabs, emptyMap()) } _uiState.update { current -> current.copy( query = submitted, query = trimmedQuery, suggestions = emptyList(), isSuggestionVisible = false, availableTabs = visibleTabs, selectedTab = selectedTab, resultsByTab = results, hasSubmittedSearch = visibleTabs.isNotEmpty(), ) } } fun onTabSelected(tab: SearchTabType) { _uiState.update { current -> if (!current.availableTabs.contains(tab)) { current } else { current.copy(selectedTab = tab) } } } private fun handleStoreSelectionChanged() { val visibleTabs = resolveVisibleTabs() _uiState.update { current -> val selectedTab = current.selectedTab?.takeIf { visibleTabs.contains(it) } ?: visibleTabs.firstOrNull() val updatedResults = if (current.hasSubmittedSearch && visibleTabs.isNotEmpty()) { buildResultsForTabs( query = current.query, visibleTabs = visibleTabs, existing = current.resultsByTab, ) } else { emptyMap() } current.copy( availableTabs = visibleTabs, selectedTab = selectedTab, resultsByTab = updatedResults, hasSubmittedSearch = current.hasSubmittedSearch && visibleTabs.isNotEmpty(), isSuggestionVisible = current.isSuggestionVisible && appLoungePreference.isPlayStoreSelected(), ) } } private fun resolveVisibleTabs(): List<SearchTabType> = stores.getStores().mapNotNull { (key, _) -> when (key) { PLAY_STORE -> SearchTabType.COMMON_APPS OPEN_SOURCE -> SearchTabType.OPEN_SOURCE PWA -> SearchTabType.PWA else -> null } } private fun buildResultsForTabs( query: String, visibleTabs: List<SearchTabType>, existing: Map<SearchTabType, List<String>>, ): Map<SearchTabType, List<String>> { if (query.isBlank()) return emptyMap() return buildMap { visibleTabs.forEach { tab -> val preserved = existing[tab] put(tab, preserved ?: generateFakeResultsFor(tab, query)) } } } private fun generateFakeResultsFor(tab: SearchTabType, query: String): List<String> { val displayQuery = query.ifBlank { "Result" } return (1..FAKE_RESULTS_PER_TAB).map { index -> when (tab) { SearchTabType.COMMON_APPS -> "Standard app $index for $displayQuery" SearchTabType.OPEN_SOURCE -> "Open source app $index for $displayQuery" SearchTabType.PWA -> "Web app $index for $displayQuery" } } } companion object { private val STORE_PREFERENCE_KEYS = setOf( PREFERENCE_SHOW_GPLAY, PREFERENCE_SHOW_FOSS, PREFERENCE_SHOW_PWA, ) } }
app/src/main/res/values/strings.xml +4 −0 Original line number Diff line number Diff line Loading @@ -26,8 +26,12 @@ <string name="menu_search" weblate_ctx="home">Search</string> <string name="menu_updates" weblate_ctx="home">Updates</string> <string name="menu_settings" weblate_ctx="home">Settings</string> <!-- Search Fragment --> <string name="search_hint" weblate_ctx="search">Search for an app</string> <string name="search_tab_standard_apps">APPS</string> <string name="search_tab_open_source">OPEN SOURCE</string> <string name="search_tab_web_apps">WEB APPS</string> <string name="no_apps_found">No apps found…</string> <!-- Categories Fragment --> Loading