Loading app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt +29 −15 Original line number Diff line number Diff line Loading @@ -66,7 +66,7 @@ import kotlinx.coroutines.launch import java.util.Locale import javax.inject.Inject @Suppress("TooManyFunctions") // TODO: Remove after refactoring is complete @Suppress("TooManyFunctions", "MaxLineLength") // TODO: Remove after refactoring is complete @AndroidEntryPoint class SearchFragment : Fragment(R.layout.fragment_search), Loading Loading @@ -98,12 +98,6 @@ class SearchFragment : lateinit var filterChipOpenSource: Chip lateinit var filterChipPWA: Chip /* * Store the string from onQueryTextSubmit() and access it from loadData() * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 */ private var searchText = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentSearchBinding.bind(view) Loading Loading @@ -134,7 +128,7 @@ class SearchFragment : if (!requireContext().isNetworkAvailable()) { return } searchViewModel.loadMore(searchText) searchViewModel.loadMore() } } }) Loading @@ -145,26 +139,42 @@ class SearchFragment : viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { searchViewModel.searchUiState.collectLatest { state -> when (state) { is SearchResultsUiState.Initial -> { searchHintLayout?.visibility = View.VISIBLE noAppsFoundLayout?.visibility = View.GONE } is SearchResultsUiState.Loading -> { noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE showLoadingUi() } is SearchResultsUiState.Success -> { noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE listAdapter?.let { observeDownloadList(it) } updateSearchResult(listAdapter, state.apps) observeScrollOfSearchResult(listAdapter) } is SearchResultsUiState.Empty -> { searchHintLayout?.visibility = View.GONE noAppsFoundLayout?.visibility = View.GONE stopLoadingUi() noAppsFoundLayout?.visibility = View.VISIBLE listAdapter?.setData(emptyList()) } is SearchResultsUiState.Error -> { stopLoadingUi() searchHintLayout?.visibility = View.GONE noAppsFoundLayout?.visibility = View.VISIBLE stopLoadingUi() } } } Loading @@ -186,9 +196,9 @@ class SearchFragment : * Compare lastSearch with searchText to avoid falsely updating to * current query text even before submitting the new search. */ if (lastSearch != searchText && positionStart == 0) { if (lastSearch != searchViewModel.searchText && positionStart == 0) { scrollToTop() lastSearch = searchText lastSearch = searchViewModel.searchText } } } Loading Loading @@ -331,7 +341,7 @@ class SearchFragment : } private fun initiateSearch() { searchViewModel.loadData(searchText) searchViewModel.loadData() } private fun showLoadingUi() { Loading Loading @@ -378,10 +388,14 @@ class SearchFragment : } } if (searchText.isEmpty() && (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()) { if (searchViewModel.searchText.isEmpty() && (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()) { searchView?.requestFocus() showKeyboard() } if (searchViewModel.searchText.isNotBlank() && searchViewModel.haveSourcesChanged()) { initiateSearch() } } private fun addDownloadProgressObservers() { Loading @@ -392,7 +406,7 @@ class SearchFragment : } private fun shouldRefreshData() = searchText.isNotEmpty() && recyclerView?.adapter != null searchViewModel.searchText.isNotEmpty() && recyclerView?.adapter != null override fun onPause() { binding.shimmerLayout.stopShimmer() Loading @@ -411,7 +425,7 @@ class SearchFragment : /* * Set the search text and call for network result. */ searchText = text searchViewModel.searchText = text initiateSearch() } return false Loading app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt +1 −0 Original line number Diff line number Diff line Loading @@ -27,6 +27,7 @@ import foundation.e.apps.data.application.data.Application * - Error: failure with optional message/throwable */ sealed interface SearchResultsUiState { data object Initial : SearchResultsUiState data object Loading : SearchResultsUiState data class Success(val apps: List<Application>) : SearchResultsUiState data object Empty : SearchResultsUiState Loading app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt +36 −15 Original line number Diff line number Diff line Loading @@ -23,6 +23,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.StoreRepository import foundation.e.apps.data.Stores import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.search.SearchRepository Loading @@ -48,7 +50,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @HiltViewModel Loading @@ -56,7 +57,8 @@ class SearchViewModel @Inject constructor( private val applicationRepository: ApplicationRepository, private val searchRepository: SearchRepository, private val privacyScoreRepository: PrivacyScoreRepository, private val appPrivacyInfoRepository: IAppPrivacyInfoRepository private val appPrivacyInfoRepository: IAppPrivacyInfoRepository, private val stores: Stores, ) : ViewModel() { companion object { Loading @@ -64,10 +66,13 @@ class SearchViewModel @Inject constructor( } private val _searchUiState: MutableStateFlow<SearchResultsUiState> = MutableStateFlow(SearchResultsUiState.Loading) MutableStateFlow(SearchResultsUiState.Initial) val searchUiState: StateFlow<SearchResultsUiState> = _searchUiState.asStateFlow() private var isLoading: Boolean = false private var isCleanApkDataLoading = false private var previousStores = mapOf<Source, StoreRepository>() @GuardedBy("mutex") private val accumulatedList = mutableListOf<Application>() Loading @@ -77,6 +82,8 @@ class SearchViewModel @Inject constructor( private var flagOpenSource: Boolean = false private var flagPWA: Boolean = false var searchText = "" val query = MutableStateFlow("") fun onQueryChanged(value: String) { Loading Loading @@ -107,33 +114,47 @@ class SearchViewModel @Inject constructor( } } fun loadData(query: String) { fun loadData() { viewModelScope.launch { _searchUiState.value = SearchResultsUiState.Loading viewModelScope.launch { mutex.withLock { accumulatedList.clear() } fetchCleanApkData(query) fetchPlayStoreData(query) fetchCleanApkData(searchText) fetchPlayStoreData(searchText) } } fun haveSourcesChanged(): Boolean { val newStores = stores.getStores() if (newStores == previousStores) { return false } previousStores = newStores.toMap() return true } private fun fetchCleanApkData(query: String) { viewModelScope.launch(Dispatchers.IO) { isCleanApkDataLoading = true val searchResults = searchRepository.getOpenSourceSearchResults(query) isCleanApkDataLoading = false emitFilteredResults(searchResults) } } fun loadMore(query: String) { fun loadMore() { viewModelScope.launch(Dispatchers.Main) { if (isLoading) { Timber.d("Search result is loading....") return@launch } fetchPlayStoreData(query) fetchPlayStoreData(searchText) } } Loading Loading @@ -235,10 +256,10 @@ class SearchViewModel @Inject constructor( val filteredList = getFilteredList() _searchUiState.value = if (filteredList.isEmpty()) { SearchResultsUiState.Empty } else { SearchResultsUiState.Success(filteredList.toList()) _searchUiState.value = when { isCleanApkDataLoading -> SearchResultsUiState.Loading filteredList.isEmpty() -> SearchResultsUiState.Empty else -> SearchResultsUiState.Success(filteredList.toList()) } } } app/src/main/res/layout/fragment_search.xml +8 −6 Original line number Diff line number Diff line <?xml version="1.0" encoding="utf-8"?><!-- ~ Apps Quickly and easily install Android apps onto your device! ~ Copyright (C) 2021 E FOUNDATION ~ Copyright (C) 2021-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 Loading @@ -14,6 +13,7 @@ ~ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see <https://www.gnu.org/licenses/>. ~ --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" Loading Loading @@ -82,13 +82,14 @@ <include android:id="@+id/searchHintLayout" tools:visibility="gone" android:visibility="gone" tools:visibility="visible" layout="@layout/fragment_search_hint" /> <include android:id="@+id/noAppsFoundLayout" android:visibility="gone" tools:visibility="gone" tools:visibility="visible" layout="@layout/layout_no_apps_found" /> <com.facebook.shimmer.ShimmerFrameLayout Loading @@ -96,7 +97,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:visibility="gone"> android:visibility="gone" tools:visibility="visible"> <LinearLayout android:layout_width="match_parent" Loading Loading
app/src/main/java/foundation/e/apps/ui/search/SearchFragment.kt +29 −15 Original line number Diff line number Diff line Loading @@ -66,7 +66,7 @@ import kotlinx.coroutines.launch import java.util.Locale import javax.inject.Inject @Suppress("TooManyFunctions") // TODO: Remove after refactoring is complete @Suppress("TooManyFunctions", "MaxLineLength") // TODO: Remove after refactoring is complete @AndroidEntryPoint class SearchFragment : Fragment(R.layout.fragment_search), Loading Loading @@ -98,12 +98,6 @@ class SearchFragment : lateinit var filterChipOpenSource: Chip lateinit var filterChipPWA: Chip /* * Store the string from onQueryTextSubmit() and access it from loadData() * Issue: https://gitlab.e.foundation/e/backlog/-/issues/5413 */ private var searchText = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentSearchBinding.bind(view) Loading Loading @@ -134,7 +128,7 @@ class SearchFragment : if (!requireContext().isNetworkAvailable()) { return } searchViewModel.loadMore(searchText) searchViewModel.loadMore() } } }) Loading @@ -145,26 +139,42 @@ class SearchFragment : viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { searchViewModel.searchUiState.collectLatest { state -> when (state) { is SearchResultsUiState.Initial -> { searchHintLayout?.visibility = View.VISIBLE noAppsFoundLayout?.visibility = View.GONE } is SearchResultsUiState.Loading -> { noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE showLoadingUi() } is SearchResultsUiState.Success -> { noAppsFoundLayout?.visibility = View.GONE searchHintLayout?.visibility = View.GONE listAdapter?.let { observeDownloadList(it) } updateSearchResult(listAdapter, state.apps) observeScrollOfSearchResult(listAdapter) } is SearchResultsUiState.Empty -> { searchHintLayout?.visibility = View.GONE noAppsFoundLayout?.visibility = View.GONE stopLoadingUi() noAppsFoundLayout?.visibility = View.VISIBLE listAdapter?.setData(emptyList()) } is SearchResultsUiState.Error -> { stopLoadingUi() searchHintLayout?.visibility = View.GONE noAppsFoundLayout?.visibility = View.VISIBLE stopLoadingUi() } } } Loading @@ -186,9 +196,9 @@ class SearchFragment : * Compare lastSearch with searchText to avoid falsely updating to * current query text even before submitting the new search. */ if (lastSearch != searchText && positionStart == 0) { if (lastSearch != searchViewModel.searchText && positionStart == 0) { scrollToTop() lastSearch = searchText lastSearch = searchViewModel.searchText } } } Loading Loading @@ -331,7 +341,7 @@ class SearchFragment : } private fun initiateSearch() { searchViewModel.loadData(searchText) searchViewModel.loadData() } private fun showLoadingUi() { Loading Loading @@ -378,10 +388,14 @@ class SearchFragment : } } if (searchText.isEmpty() && (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()) { if (searchViewModel.searchText.isEmpty() && (recyclerView?.adapter as ApplicationListRVAdapter).currentList.isEmpty()) { searchView?.requestFocus() showKeyboard() } if (searchViewModel.searchText.isNotBlank() && searchViewModel.haveSourcesChanged()) { initiateSearch() } } private fun addDownloadProgressObservers() { Loading @@ -392,7 +406,7 @@ class SearchFragment : } private fun shouldRefreshData() = searchText.isNotEmpty() && recyclerView?.adapter != null searchViewModel.searchText.isNotEmpty() && recyclerView?.adapter != null override fun onPause() { binding.shimmerLayout.stopShimmer() Loading @@ -411,7 +425,7 @@ class SearchFragment : /* * Set the search text and call for network result. */ searchText = text searchViewModel.searchText = text initiateSearch() } return false Loading
app/src/main/java/foundation/e/apps/ui/search/SearchResultsUiState.kt +1 −0 Original line number Diff line number Diff line Loading @@ -27,6 +27,7 @@ import foundation.e.apps.data.application.data.Application * - Error: failure with optional message/throwable */ sealed interface SearchResultsUiState { data object Initial : SearchResultsUiState data object Loading : SearchResultsUiState data class Success(val apps: List<Application>) : SearchResultsUiState data object Empty : SearchResultsUiState Loading
app/src/main/java/foundation/e/apps/ui/search/SearchViewModel.kt +36 −15 Original line number Diff line number Diff line Loading @@ -23,6 +23,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.StoreRepository import foundation.e.apps.data.Stores import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.search.SearchRepository Loading @@ -48,7 +50,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @HiltViewModel Loading @@ -56,7 +57,8 @@ class SearchViewModel @Inject constructor( private val applicationRepository: ApplicationRepository, private val searchRepository: SearchRepository, private val privacyScoreRepository: PrivacyScoreRepository, private val appPrivacyInfoRepository: IAppPrivacyInfoRepository private val appPrivacyInfoRepository: IAppPrivacyInfoRepository, private val stores: Stores, ) : ViewModel() { companion object { Loading @@ -64,10 +66,13 @@ class SearchViewModel @Inject constructor( } private val _searchUiState: MutableStateFlow<SearchResultsUiState> = MutableStateFlow(SearchResultsUiState.Loading) MutableStateFlow(SearchResultsUiState.Initial) val searchUiState: StateFlow<SearchResultsUiState> = _searchUiState.asStateFlow() private var isLoading: Boolean = false private var isCleanApkDataLoading = false private var previousStores = mapOf<Source, StoreRepository>() @GuardedBy("mutex") private val accumulatedList = mutableListOf<Application>() Loading @@ -77,6 +82,8 @@ class SearchViewModel @Inject constructor( private var flagOpenSource: Boolean = false private var flagPWA: Boolean = false var searchText = "" val query = MutableStateFlow("") fun onQueryChanged(value: String) { Loading Loading @@ -107,33 +114,47 @@ class SearchViewModel @Inject constructor( } } fun loadData(query: String) { fun loadData() { viewModelScope.launch { _searchUiState.value = SearchResultsUiState.Loading viewModelScope.launch { mutex.withLock { accumulatedList.clear() } fetchCleanApkData(query) fetchPlayStoreData(query) fetchCleanApkData(searchText) fetchPlayStoreData(searchText) } } fun haveSourcesChanged(): Boolean { val newStores = stores.getStores() if (newStores == previousStores) { return false } previousStores = newStores.toMap() return true } private fun fetchCleanApkData(query: String) { viewModelScope.launch(Dispatchers.IO) { isCleanApkDataLoading = true val searchResults = searchRepository.getOpenSourceSearchResults(query) isCleanApkDataLoading = false emitFilteredResults(searchResults) } } fun loadMore(query: String) { fun loadMore() { viewModelScope.launch(Dispatchers.Main) { if (isLoading) { Timber.d("Search result is loading....") return@launch } fetchPlayStoreData(query) fetchPlayStoreData(searchText) } } Loading Loading @@ -235,10 +256,10 @@ class SearchViewModel @Inject constructor( val filteredList = getFilteredList() _searchUiState.value = if (filteredList.isEmpty()) { SearchResultsUiState.Empty } else { SearchResultsUiState.Success(filteredList.toList()) _searchUiState.value = when { isCleanApkDataLoading -> SearchResultsUiState.Loading filteredList.isEmpty() -> SearchResultsUiState.Empty else -> SearchResultsUiState.Success(filteredList.toList()) } } }
app/src/main/res/layout/fragment_search.xml +8 −6 Original line number Diff line number Diff line <?xml version="1.0" encoding="utf-8"?><!-- ~ Apps Quickly and easily install Android apps onto your device! ~ Copyright (C) 2021 E FOUNDATION ~ Copyright (C) 2021-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 Loading @@ -14,6 +13,7 @@ ~ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see <https://www.gnu.org/licenses/>. ~ --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" Loading Loading @@ -82,13 +82,14 @@ <include android:id="@+id/searchHintLayout" tools:visibility="gone" android:visibility="gone" tools:visibility="visible" layout="@layout/fragment_search_hint" /> <include android:id="@+id/noAppsFoundLayout" android:visibility="gone" tools:visibility="gone" tools:visibility="visible" layout="@layout/layout_no_apps_found" /> <com.facebook.shimmer.ShimmerFrameLayout Loading @@ -96,7 +97,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:visibility="gone"> android:visibility="gone" tools:visibility="visible"> <LinearLayout android:layout_width="match_parent" Loading