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

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

feat: implement tabs for search results

Based on the store's visibility set in the Settings screen, search tabs will be shown accordingly.
parent 0edaa76c
Loading
Loading
Loading
Loading
Loading
+13 −0
Original line number Diff line number Diff line
@@ -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
@@ -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) {
+208 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.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.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
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
import kotlinx.coroutines.launch

@Composable
fun SearchResultsContent(
    tabs: List<SearchTabType>,
    selectedTab: SearchTabType,
    resultsByTab: Map<SearchTabType, List<String>>,
    onTabSelected: (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 },
    )

    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 != selectedTab) {
                onTabSelected(tab)
            }
        }
    }

    Column(
        modifier = modifier.fillMaxSize(),
    ) {
        SearchTabs(
            tabs = tabs,
            selectedIndex = pagerState.currentPage,
            onTabSelected = { tab, index ->
                coroutineScope.launch {
                    pagerState.animateScrollToPage(index)
                }
                onTabSelected(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 SearchTabs(
    tabs: List<SearchTabType>,
    selectedIndex: Int,
    onTabSelected: (SearchTabType, Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    TabRow(
        modifier = modifier,
        selectedTabIndex = selectedIndex,
        indicator = { tabPositions ->
            if (selectedIndex in tabPositions.indices) {
                TabRowDefaults.Indicator(
                    modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
                    color = MaterialTheme.colorScheme.primary,
                )
            }
        },
        containerColor = MaterialTheme.colorScheme.background,
        contentColor = MaterialTheme.colorScheme.onBackground,
        divider = { Divider(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.08f)) },
    ) {
        tabs.forEachIndexed { index, tab ->
            val label = stringResource(id = tab.toLabelRes())
            Tab(
                selected = index == selectedIndex,
                onClick = { onTabSelected(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,
                    ),
                )
            }
        }
    }
}

@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(),
            )
        }
    }
}

@StringRes
private fun SearchTabType.toLabelRes(): Int = when (this) {
    SearchTabType.STANDARD_APPS -> R.string.search_tab_standard_apps
    SearchTabType.OPEN_SOURCE -> R.string.search_tab_open_source
    SearchTabType.WEB_APPS -> R.string.search_tab_web_apps
}

@Preview(showBackground = true)
@Composable
private fun SearchResultsContentPreview() {
    AppTheme(darkTheme = true) {
        SearchResultsContent(
            tabs = listOf(
                SearchTabType.STANDARD_APPS,
                SearchTabType.OPEN_SOURCE,
                SearchTabType.WEB_APPS,
            ),
            selectedTab = SearchTabType.OPEN_SOURCE,
            resultsByTab = mapOf(
                SearchTabType.STANDARD_APPS to listOf("Standard app 1 for Firefox", "Standard app 2 for Firefox"),
                SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Firefox"),
                SearchTabType.WEB_APPS to listOf("Web app 1 for Firefox", "Web app 2 for Firefox"),
            ),
            onTabSelected = {},
        )
    }
}
+51 −11
Original line number Diff line number Diff line
@@ -18,22 +18,26 @@

package foundation.e.apps.ui.compose.screens

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import foundation.e.apps.ui.compose.components.SearchEmptyState
import foundation.e.apps.ui.compose.components.SearchResultsContent
import foundation.e.apps.ui.compose.components.SearchSuggestionsDropdown
import foundation.e.apps.ui.compose.components.SearchTopBar
import foundation.e.apps.ui.compose.theme.AppTheme
import foundation.e.apps.ui.search.v2.SearchTabType
import foundation.e.apps.ui.search.v2.SearchUiState

@Composable
@@ -44,6 +48,7 @@ fun SearchScreen(
    onClearQuery: () -> Unit,
    onSubmitSearch: (String) -> Unit,
    onSuggestionSelected: (String) -> Unit,
    onTabSelected: (SearchTabType) -> Unit,
) {
    val focusManager = LocalFocusManager.current
    val focusRequester = remember { FocusRequester() }
@@ -63,12 +68,35 @@ fun SearchScreen(
            )
        },
    ) { innerPadding ->
        Column(
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(horizontal = 16.dp),
        ) {
            val shouldShowResults = state.hasSubmittedSearch &&
                    state.selectedTab != null &&
                    state.availableTabs.isNotEmpty()

            if (shouldShowResults) {
                SearchResultsContent(
                    tabs = state.availableTabs,
                    selectedTab = state.selectedTab!!,
                    resultsByTab = state.resultsByTab,
                    onTabSelected = onTabSelected,
                    modifier = Modifier
                        .align(Alignment.TopStart)
                        .fillMaxWidth()
                        .padding(top = 8.dp),
                )
            } else {
                SearchEmptyState(
                    modifier = Modifier
                        .align(Alignment.Center)
                        .fillMaxWidth(),
                )
            }

            if (state.isSuggestionVisible) {
                SearchSuggestionsDropdown(
                    suggestions = state.suggestions,
@@ -76,17 +104,16 @@ fun SearchScreen(
                        onSuggestionSelected(it)
                        focusManager.clearFocus(force = false)
                    },
                    modifier = Modifier.padding(top = 8.dp),
                )
            }
            SearchEmptyState(
                    modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth(),
                        .align(Alignment.TopCenter)
                        .fillMaxWidth()
                        .padding(top = 8.dp)
                        .zIndex(1f),
                )
            }
        }
    }
}

@Preview(showBackground = false)
@Composable
@@ -94,15 +121,28 @@ private fun SearchScreenPreview() {
    AppTheme(darkTheme = true) {
        SearchScreen(
            state = SearchUiState(
                query = "t",
                suggestions = listOf("Telegram", "Telegram FOSS", "Telegram X"),
                isSuggestionVisible = true,
                query = "Telegram",
                suggestions = emptyList(),
                isSuggestionVisible = false,
                availableTabs = listOf(
                    SearchTabType.STANDARD_APPS,
                    SearchTabType.OPEN_SOURCE,
                    SearchTabType.WEB_APPS,
                ),
                selectedTab = SearchTabType.STANDARD_APPS,
                resultsByTab = mapOf(
                    SearchTabType.STANDARD_APPS to listOf("Standard app 1 for Telegram"),
                    SearchTabType.OPEN_SOURCE to listOf("Open source app 1 for Telegram"),
                    SearchTabType.WEB_APPS to listOf("Web app 1 for Telegram"),
                ),
                hasSubmittedSearch = true,
            ),
            onQueryChange = {},
            onBackClick = {},
            onClearQuery = {},
            onSubmitSearch = {},
            onSuggestionSelected = {},
            onTabSelected = {},
        )
    }
}
+1 −0
Original line number Diff line number Diff line
@@ -32,6 +32,7 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
                    onClearQuery = { searchViewModelV2.onClearQuery() },
                    onSubmitSearch = { submitted -> searchViewModelV2.onSubmitSearch(submitted) },
                    onSuggestionSelected = { suggestion -> searchViewModelV2.onSuggestionSelected(suggestion) },
                    onTabSelected = { tab -> searchViewModelV2.onTabSelected(tab) },
                )
            }
        }
+186 −10
Original line number Diff line number Diff line
@@ -18,10 +18,14 @@

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 javax.inject.Inject
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.preference.AppLoungePreference
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,31 +33,94 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
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 {
    STANDARD_APPS,
    OPEN_SOURCE,
    WEB_APPS,
}

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,
) : 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(),
@@ -62,6 +129,7 @@ class SearchViewModelV2 @Inject constructor(
            }
            return
        }

        suggestionJob = viewModelScope.launch {
            delay(SUGGESTION_DEBOUNCE_MS)
            val suggestions = suggestionSource.suggest(newQuery)
@@ -75,27 +143,135 @@ class SearchViewModelV2 @Inject constructor(
    }

    fun onSuggestionSelected(suggestion: String) {
        onSubmitSearch(suggestion)
    }

    fun onClearQuery() {
        suggestionJob?.cancel()
        _uiState.update { current ->
            if (current.hasSubmittedSearch && current.availableTabs.isNotEmpty()) {
                current.copy(
                    query = "",
                    suggestions = emptyList(),
                    isSuggestionVisible = false,
                )
            } else {
                val visibleTabs = resolveVisibleTabs()
                current.copy(
                query = suggestion,
                    query = "",
                    suggestions = emptyList(),
                    isSuggestionVisible = false,
                    hasSubmittedSearch = false,
                    resultsByTab = emptyMap(),
                    availableTabs = visibleTabs,
                    selectedTab = visibleTabs.firstOrNull(),
                )
            }
        }

    fun onClearQuery() {
        suggestionJob?.cancel()
        _uiState.value = SearchUiState()
    }

    fun onSubmitSearch(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> = buildList {
        if (appLoungePreference.isPlayStoreSelected()) add(SearchTabType.STANDARD_APPS)
        if (appLoungePreference.isOpenSourceSelected()) add(SearchTabType.OPEN_SOURCE)
        if (appLoungePreference.isPWASelected()) add(SearchTabType.WEB_APPS)
    }

    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.STANDARD_APPS -> "Standard app $index for $displayQuery"
                SearchTabType.OPEN_SOURCE -> "Open source app $index for $displayQuery"
                SearchTabType.WEB_APPS -> "Web app $index for $displayQuery"
            }
        }
    }

    companion object {
        private val STORE_PREFERENCE_KEYS = setOf(
            PREFERENCE_SHOW_GPLAY,
            PREFERENCE_SHOW_FOSS,
            PREFERENCE_SHOW_PWA,
        )
    }
}
Loading