Loading app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt +38 −17 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) } } } } Loading Loading @@ -201,7 +220,7 @@ class SearchFragment : } private fun showData() { stopLoadingUI() stopLoadingUi() noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE } Loading Loading @@ -257,7 +276,7 @@ class SearchFragment : binding.filterChipGroup.isSingleSelection = true val listener = OnCheckedChangeListener { _, _ -> showLoadingUI() showLoadingUi() searchViewModel.setFilterFlags( flagOpenSource = filterChipOpenSource.isChecked, flagPWA = filterChipPWA.isChecked, Loading Loading @@ -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 } Loading app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt 0 → 100644 +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 } app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt +22 −40 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) Loading @@ -127,7 +119,7 @@ class SearchViewModel @Inject constructor( Timber.d("Search result is loading....") return@launch } fetchGplayData(query) fetchPlayStoreData(query) } } Loading @@ -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 Loading Loading @@ -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()) } } } Loading
app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt +38 −17 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) } } } } Loading Loading @@ -201,7 +220,7 @@ class SearchFragment : } private fun showData() { stopLoadingUI() stopLoadingUi() noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE } Loading Loading @@ -257,7 +276,7 @@ class SearchFragment : binding.filterChipGroup.isSingleSelection = true val listener = OnCheckedChangeListener { _, _ -> showLoadingUI() showLoadingUi() searchViewModel.setFilterFlags( flagOpenSource = filterChipOpenSource.isChecked, flagPWA = filterChipPWA.isChecked, Loading Loading @@ -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 } Loading
app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt 0 → 100644 +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 }
app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt +22 −40 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) Loading @@ -127,7 +119,7 @@ class SearchViewModel @Inject constructor( Timber.d("Search result is loading....") return@launch } fetchGplayData(query) fetchPlayStoreData(query) } } Loading @@ -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 Loading Loading @@ -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()) } } }