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

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

Merge branch '3665-refactor-search-ui' into 'main'

refactor: switch to StateFlow from LiveData in search results and suggestions

See merge request !592
parents 6624bec4 1f9c72ad
Loading
Loading
Loading
Loading
Loading
+0 −2
Original line number Diff line number Diff line
@@ -27,7 +27,6 @@ import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.enums.Status
import foundation.e.apps.install.pkg.AppLoungePackageManager
import foundation.e.apps.install.pkg.PwaManager
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

@@ -57,7 +56,6 @@ class ApplicationDataManager @Inject constructor(
    }

    suspend fun getAppFilterLevel(application: Application): FilterLevel {
        Timber.i("getAppFilterLevel: $application")
        return when {
            application.package_name.isBlank() -> FilterLevel.UNKNOWN
            !application.isFree && application.price.isBlank() -> FilterLevel.UI
+46 −37
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 * Copyright (C) 2021-2024 MURENA SAS
 *
 * This program is free software: you can redistribute it and/or modify
@@ -35,7 +36,9 @@ import androidx.cursoradapter.widget.SimpleCursorAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -58,9 +61,7 @@ import foundation.e.apps.ui.PrivacyInfoViewModel
import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment
import foundation.e.apps.ui.applicationlist.ApplicationListRVAdapter
import foundation.e.apps.utils.isNetworkAvailable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
@@ -103,8 +104,6 @@ class SearchFragment :
     */
    private var searchText = ""

    private var searchJob: Job? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentSearchBinding.bind(view)
@@ -128,8 +127,6 @@ class SearchFragment :

        setupSearchFilters()

        initiateSearch()

        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
@@ -144,18 +141,34 @@ class SearchFragment :
    }

    private fun observeSearchResult(listAdapter: ApplicationListRVAdapter?) {
        searchViewModel.searchResult.observe(viewLifecycleOwner) { result ->
            val apps = result.data?.first
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                searchViewModel.searchUiState.collectLatest { state ->
                    when (state) {
                        is SearchResultsUiState.Loading -> {
                            noAppsFoundLayout?.visibility = View.GONE
                            showLoadingUi()
                        }

                        is SearchResultsUiState.Success -> {
                            listAdapter?.let { observeDownloadList(it) }
                            updateSearchResult(listAdapter, state.apps)
                            observeScrollOfSearchResult(listAdapter)
                        }

                        is SearchResultsUiState.Empty -> {
                            stopLoadingUi()
                            noAppsFoundLayout?.visibility = View.VISIBLE
                            listAdapter?.setData(emptyList())
                        }

            if (apps.isNullOrEmpty()) {
                        is SearchResultsUiState.Error -> {
                            stopLoadingUi()
                            noAppsFoundLayout?.visibility = View.VISIBLE
            } else {
                listAdapter?.let { adapter ->
                    observeDownloadList(adapter)
                        }
                    }
            updateSearchResult(listAdapter, apps ?: emptyList())
            observeScrollOfSearchResult(listAdapter)
                }
            }
        }
    }

@@ -201,7 +214,7 @@ class SearchFragment :
    }

    private fun showData() {
        stopLoadingUI()
        stopLoadingUi()
        noAppsFoundLayout?.visibility = View.GONE
        searchHintLayout?.visibility = View.GONE
    }
@@ -247,8 +260,12 @@ class SearchFragment :
            CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER
        )

        searchViewModel.searchSuggestions.observe(viewLifecycleOwner) {
            it?.let { populateSuggestionsAdapter(it) }
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                searchViewModel.searchSuggestions.collectLatest {
                    populateSuggestionsAdapter(it)
                }
            }
        }
    }

@@ -257,7 +274,7 @@ class SearchFragment :
        binding.filterChipGroup.isSingleSelection = true

        val listener = OnCheckedChangeListener { _, _ ->
            showLoadingUI()
            showLoadingUi()
            searchViewModel.setFilterFlags(
                flagOpenSource = filterChipOpenSource.isChecked,
                flagPWA = filterChipPWA.isChecked,
@@ -304,24 +321,25 @@ class SearchFragment :
        appInstallList: List<AppInstall>,
        applicationListRVAdapter: ApplicationListRVAdapter
    ) {
        val searchList =
            searchViewModel.searchResult.value?.data?.first?.toMutableList() ?: emptyList()
        val searchList = when (val state = searchViewModel.searchUiState.value) {
            is SearchResultsUiState.Success -> state.apps.toMutableList()
            else -> emptyList()
        }

        mainActivityViewModel.updateStatusOfFusedApps(searchList, appInstallList)
        updateSearchResult(applicationListRVAdapter, searchList)
    }

    private fun initiateSearch() {
        showLoadingUI()
        searchViewModel.loadData(searchText)
    }

    private fun showLoadingUI() {
    private fun showLoadingUi() {
        shimmerLayout?.visibility = View.VISIBLE
        shimmerLayout?.startShimmer()
    }

    private fun stopLoadingUI() {
    private fun stopLoadingUi() {
        shimmerLayout?.stopShimmer()
        shimmerLayout?.visibility = View.GONE
    }
@@ -400,24 +418,16 @@ class SearchFragment :
    }

    override fun onQueryTextChange(newText: String?): Boolean {
        newText?.takeIf { it.isNotEmpty() }?.let(::doDebouncedSearch)
        searchViewModel.onQueryChanged(newText.orEmpty())
        return true
    }

    private fun doDebouncedSearch(text: String) {
        searchJob?.cancel()
        searchJob = lifecycleScope.launch(Dispatchers.Main.immediate) {
            delay(SEARCH_DEBOUNCE_DELAY_MILLIS)
            searchViewModel.getSearchSuggestions(text)
        }
    }

    override fun onSuggestionSelect(position: Int): Boolean {
        return true
    }

    override fun onSuggestionClick(position: Int): Boolean {
        searchViewModel.searchSuggestions.value?.let {
        searchViewModel.searchSuggestions.value.let {
            if (it.isNotEmpty()) {
                searchView?.setQuery(it[position].suggestion, true)
            }
@@ -483,7 +493,6 @@ class SearchFragment :
    }

    companion object {
        private const val SEARCH_DEBOUNCE_DELAY_MILLIS = 500L
        private const val SCROLL_TO_TOP_DELAY_MILLIS = 100L
    }
}
+35 −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.search

import foundation.e.apps.data.application.data.Application

/**
 * Represents UI state of search results.
 * - Loading: when fetching or applying filters
 * - Success: non-empty results
 * - Empty: no results
 * - Error: failure with optional message/throwable
 */
sealed interface SearchResultsUiState {
    data object Loading : SearchResultsUiState
    data class Success(val apps: List<Application>) : SearchResultsUiState
    data object Empty : SearchResultsUiState
    data class Error(val message: String? = null, val throwable: Throwable? = null) :
        SearchResultsUiState
}
+47 −51
Original line number Diff line number Diff line
@@ -19,8 +19,6 @@
package foundation.e.apps.ui.search

import androidx.annotation.GuardedBy
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -34,8 +32,18 @@ import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.exodus.repositories.IAppPrivacyInfoRepository
import foundation.e.apps.data.exodus.repositories.PrivacyScoreRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -51,11 +59,13 @@ class SearchViewModel @Inject constructor(
    private val appPrivacyInfoRepository: IAppPrivacyInfoRepository
) : ViewModel() {

    val searchSuggestions: MutableLiveData<List<SearchSuggestion>> = MutableLiveData()
    companion object {
        private const val SEARCH_DEBOUNCE_DELAY_MILLIS = 500L
    }

    private val _searchResult: MutableLiveData<SearchResult> =
        MutableLiveData()
    val searchResult: LiveData<SearchResult> = _searchResult
    private val _searchUiState: MutableStateFlow<SearchResultsUiState> =
        MutableStateFlow(SearchResultsUiState.Loading)
    val searchUiState: StateFlow<SearchResultsUiState> = _searchUiState.asStateFlow()

    private var isLoading: Boolean = false

@@ -67,6 +77,22 @@ class SearchViewModel @Inject constructor(
    private var flagOpenSource: Boolean = false
    private var flagPWA: Boolean = false

    val query = MutableStateFlow("")

    fun onQueryChanged(value: String) {
        query.value = value
    }

    @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
    val searchSuggestions: StateFlow<List<SearchSuggestion>> = query
        .debounce(SEARCH_DEBOUNCE_DELAY_MILLIS)
        .mapLatest {
            if (it.isBlank()) emptyList()
            else searchRepository.getSearchSuggestions(it)
        }
        .distinctUntilChanged()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())

    fun setFilterFlags(
        flagNoTrackers: Boolean = false,
        flagOpenSource: Boolean = false,
@@ -81,39 +107,19 @@ class SearchViewModel @Inject constructor(
        }
    }

    fun getSearchSuggestions(query: String) {
        viewModelScope.launch(Dispatchers.IO) {
            searchSuggestions.postValue(
                searchRepository.getSearchSuggestions(query)
            )
        }
    }

    fun loadData(query: String) {
        if (query.isBlank()) return
        _searchUiState.value = SearchResultsUiState.Loading

        viewModelScope.launch {
            mutex.withLock {
                accumulatedList.clear()
            }
        }

        _searchResult.postValue(
            ResultSupreme.Success(
                Pair(emptyList(), false)
            )
        )

            fetchCleanApkData(query)
        fetchGplayData(query)
            fetchPlayStoreData(query)
        }
    }

    /*
     * Observe data from Fused API and publish the result in searchResult.
     * This allows us to show apps as they are being fetched from the network,
     * without having to wait for all of the apps.
     * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5171
     */
    private fun fetchCleanApkData(query: String) {
        viewModelScope.launch(Dispatchers.IO) {
            val searchResults = searchRepository.getOpenSourceSearchResults(query)
@@ -127,7 +133,7 @@ class SearchViewModel @Inject constructor(
                Timber.d("Search result is loading....")
                return@launch
            }
            fetchGplayData(query)
            fetchPlayStoreData(query)
        }
    }

@@ -135,7 +141,7 @@ class SearchViewModel @Inject constructor(
        return apps.filter { it.name.isNotBlank() }.sortedBy { it.source }.distinctBy { it.package_name }
    }

    private fun fetchGplayData(query: String) {
    private fun fetchPlayStoreData(query: String) {
        viewModelScope.launch(Dispatchers.IO) {
            isLoading = true

@@ -215,34 +221,24 @@ class SearchViewModel @Inject constructor(
    /**
     * Pass [result] as null to re-emit already loaded search results with new filters.
     */
    private suspend fun emitFilteredResults(
        result: SearchResult? = null
    ) {

        // When filters are changed but no data is fetched yet
        if (result == null && _searchResult.value == null) {
            return
        }

    private suspend fun emitFilteredResults(result: SearchResult? = null) {
        if (result != null && !result.isSuccess()) {
            _searchResult.postValue(result!!)
            _searchUiState.value = SearchResultsUiState.Error(result.message, result.exception)
            return
        }

        if (result != null) {
            result.data?.first?.let {
                mutex.withLock {
                    accumulatedList.addAll(it)
                }
            result.data?.first?.let { newItems ->
                mutex.withLock { accumulatedList.addAll(newItems) }
            }
        }

        val filteredList = getFilteredList()

        _searchResult.postValue(
            ResultSupreme.Success(
                Pair(filteredList.toList(), false)
            )
        )
        _searchUiState.value = if (filteredList.isEmpty()) {
            SearchResultsUiState.Empty
        } else {
            SearchResultsUiState.Success(filteredList.toList())
        }
    }
}