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

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

refactor: migrate search result handling to use StateFlow from LiveData

parent 08753688
Loading
Loading
Loading
Loading
Loading
+38 −17
Original line number Diff line number Diff line
@@ -35,7 +35,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
@@ -61,6 +63,7 @@ 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
@@ -144,18 +147,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()
                        }

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

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

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

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

    private fun showData() {
        stopLoadingUI()
        stopLoadingUi()
        noAppsFoundLayout?.visibility = View.GONE
        searchHintLayout?.visibility = View.GONE
    }
@@ -257,7 +276,7 @@ class SearchFragment :
        binding.filterChipGroup.isSingleSelection = true

        val listener = OnCheckedChangeListener { _, _ ->
            showLoadingUI()
            showLoadingUi()
            searchViewModel.setFilterFlags(
                flagOpenSource = filterChipOpenSource.isChecked,
                flagPWA = filterChipPWA.isChecked,
@@ -304,24 +323,26 @@ 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()
        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
    }
+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
}
+22 −40
Original line number Diff line number Diff line
@@ -19,7 +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
@@ -36,6 +35,9 @@ import foundation.e.apps.data.exodus.repositories.PrivacyScoreRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -53,9 +55,9 @@ class SearchViewModel @Inject constructor(

    val searchSuggestions: MutableLiveData<List<SearchSuggestion>> = MutableLiveData()

    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

@@ -92,28 +94,18 @@ class SearchViewModel @Inject constructor(
    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 +119,7 @@ class SearchViewModel @Inject constructor(
                Timber.d("Search result is loading....")
                return@launch
            }
            fetchGplayData(query)
            fetchPlayStoreData(query)
        }
    }

@@ -135,7 +127,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 +207,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())
        }
    }
}