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

Commit f05af72d authored by Fahim M. Choudhury's avatar Fahim M. Choudhury Committed by Fahim M. Choudhury
Browse files

chore: add tabs for showing search results

parent de32f9bc
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) {
+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 = {},
        )
    }
}
+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
}
+197 −9
Original line number Diff line number Diff line
@@ -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
@@ -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(),
@@ -63,6 +135,7 @@ class SearchViewModelV2 @Inject constructor(
            }
            return
        }

        suggestionJob = viewModelScope.launch {
            delay(SUGGESTION_DEBOUNCE_MS)
            val suggestions = suggestionSource.suggest(newQuery)
@@ -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,
        )
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -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 -->