From fa1573a1ecb0d989464013fa83a2917bec9c59ad Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 21 Aug 2025 17:30:52 -0300 Subject: [PATCH] Add search filtering to My Apps --- next/src/main/kotlin/org/fdroid/ui/Main.kt | 17 ++-- .../main/kotlin/org/fdroid/ui/apps/MyApps.kt | 81 ++++++++++++++----- .../kotlin/org/fdroid/ui/apps/MyAppsInfo.kt | 16 ++++ .../org/fdroid/ui/apps/MyAppsPresenter.kt | 24 +++--- .../org/fdroid/ui/apps/MyAppsViewModel.kt | 16 ++-- next/src/main/res/values/strings-next.xml | 3 + 6 files changed, 120 insertions(+), 37 deletions(-) create mode 100644 next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt diff --git a/next/src/main/kotlin/org/fdroid/ui/Main.kt b/next/src/main/kotlin/org/fdroid/ui/Main.kt index 14bd2e43b..f99c18ff5 100644 --- a/next/src/main/kotlin/org/fdroid/ui/Main.kt +++ b/next/src/main/kotlin/org/fdroid/ui/Main.kt @@ -33,6 +33,7 @@ import org.fdroid.database.AppListSortOrder import org.fdroid.fdroid.ui.theme.FDroidContent import org.fdroid.next.R import org.fdroid.ui.apps.MyApps +import org.fdroid.ui.apps.MyAppsInfo import org.fdroid.ui.apps.MyAppsViewModel import org.fdroid.ui.details.AppDetails import org.fdroid.ui.details.AppDetailsViewModel @@ -104,10 +105,18 @@ fun Main(onListeningForIntent: () -> Unit = {}) { }, ) { val myAppsViewModel = hiltViewModel() - val myAppsModel = - myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value + val myAppsInfo = object : MyAppsInfo { + override val model = + myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value + + override fun refresh() = myAppsViewModel.refresh() + override fun changeSortOrder(sort: AppListSortOrder) = + myAppsViewModel.changeSortOrder(sort) + + override fun search(query: String) = myAppsViewModel.search(query) + } MyApps( - myAppsModel = myAppsModel, + myAppsInfo = myAppsInfo, currentPackageName = if (isBigScreen) { (backStack.last() as? NavigationKey.AppDetails)?.packageName } else null, @@ -115,9 +124,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) { backStack.add(NavigationKey.AppDetails(it)) }, onNav = { backStack.add(it) }, - onRefresh = myAppsViewModel::refresh, isBigScreen = isBigScreen, - onSortChanged = myAppsViewModel::changeSortOrder, ) } entry( diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt index 7a5ca5ac9..9a5af700d 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt @@ -1,8 +1,11 @@ package org.fdroid.ui.apps +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.RestrictTo import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -11,6 +14,7 @@ import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SortByAlpha import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu @@ -30,11 +34,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect @@ -45,6 +51,7 @@ import org.fdroid.fdroid.ui.theme.FDroidContent import org.fdroid.next.R import org.fdroid.ui.BottomBar import org.fdroid.ui.NavigationKey +import org.fdroid.ui.lists.TopSearchBar import org.fdroid.ui.utils.BigLoadingIndicator import org.fdroid.ui.utils.Names import org.fdroid.ui.utils.getPreviewVersion @@ -53,29 +60,46 @@ import java.util.concurrent.TimeUnit.DAYS @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) fun MyApps( - myAppsModel: MyAppsModel, + myAppsInfo: MyAppsInfo, currentPackageName: String?, onAppItemClick: (String) -> Unit, onNav: (NavKey) -> Unit, - onSortChanged: (AppListSortOrder) -> Unit, - onRefresh: () -> Unit, isBigScreen: Boolean, modifier: Modifier = Modifier, ) { + val myAppsModel = myAppsInfo.model LifecycleStartEffect(myAppsModel) { - onRefresh() + myAppsInfo.refresh() onStopOrDispose { } } val updatableApps = myAppsModel.appUpdates val installedApps = myAppsModel.installedApps val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + var searchActive by rememberSaveable { mutableStateOf(false) } + val onSearchCleared = { myAppsInfo.search("") } + // when search bar is shown, back button closes it again + BackHandler(enabled = searchActive) { + searchActive = false + onSearchCleared() + } + val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Scaffold( topBar = { - TopAppBar( + if (searchActive) { + TopSearchBar(onSearch = myAppsInfo::search, onSearchCleared) { + onBackPressedDispatcher?.onBackPressed() + } + } else TopAppBar( title = { Text(stringResource(R.string.menu_apps_my)) }, actions = { + IconButton(onClick = { searchActive = true }) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.menu_search), + ) + } var sortByMenuExpanded by remember { mutableStateOf(false) } IconButton(onClick = { sortByMenuExpanded = !sortByMenuExpanded }) { Icon(Icons.Filled.MoreVert, null) @@ -96,7 +120,7 @@ fun MyApps( ) }, onClick = { - onSortChanged(AppListSortOrder.NAME) + myAppsInfo.changeSortOrder(AppListSortOrder.NAME) sortByMenuExpanded = false }, ) @@ -112,7 +136,7 @@ fun MyApps( ) }, onClick = { - onSortChanged(LAST_UPDATED) + myAppsInfo.changeSortOrder(LAST_UPDATED) sortByMenuExpanded = false }, ) @@ -127,7 +151,20 @@ fun MyApps( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> if (updatableApps == null && installedApps == null) BigLoadingIndicator() - else LazyColumn( + else if (updatableApps.isNullOrEmpty() && installedApps.isNullOrEmpty()) { + Text( + text = if (searchActive) { + stringResource(R.string.search_my_apps_no_results) + } else { + stringResource(R.string.my_apps_empty) + }, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .padding(16.dp), + ) + } else LazyColumn( modifier .padding(paddingValues) .then( @@ -212,19 +249,24 @@ fun MyApps( @Preview @Composable fun MyAppsLoadingPreview() { + val info = object : MyAppsInfo { + override val model = MyAppsModel( + appUpdates = null, + installedApps = null, + sortOrder = AppListSortOrder.NAME, + ) + + override fun refresh() {} + override fun changeSortOrder(sort: AppListSortOrder) {} + override fun search(query: String) {} + } FDroidContent { MyApps( - myAppsModel = MyAppsModel( - appUpdates = null, - installedApps = null, - sortOrder = AppListSortOrder.NAME, - ), + myAppsInfo = info, currentPackageName = null, onAppItemClick = {}, onNav = {}, - onSortChanged = { }, isBigScreen = false, - onRefresh = {}, ) } } @@ -272,13 +314,16 @@ fun MyAppsPreview() { sortOrder = AppListSortOrder.NAME, ) MyApps( - myAppsModel = model, + myAppsInfo = object : MyAppsInfo { + override val model = model + override fun refresh() {} + override fun changeSortOrder(sort: AppListSortOrder) {} + override fun search(query: String) {} + }, currentPackageName = null, onAppItemClick = {}, onNav = {}, - onSortChanged = { }, isBigScreen = false, - onRefresh = {}, ) } } diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt new file mode 100644 index 000000000..d60f19d0e --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt @@ -0,0 +1,16 @@ +package org.fdroid.ui.apps + +import org.fdroid.database.AppListSortOrder + +interface MyAppsInfo { + val model: MyAppsModel + fun refresh() + fun changeSortOrder(sort: AppListSortOrder) + fun search(query: String) +} + +data class MyAppsModel( + val appUpdates: List? = null, + val installedApps: List? = null, + val sortOrder: AppListSortOrder = AppListSortOrder.NAME, +) diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt index c5336f86a..d86695390 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import kotlinx.coroutines.flow.StateFlow import org.fdroid.database.AppListSortOrder +import org.fdroid.ui.utils.normalize import java.text.Collator import java.util.Locale @@ -13,26 +14,37 @@ import java.util.Locale fun MyAppsPresenter( appUpdatesFlow: StateFlow?>, installedAppsFlow: StateFlow?>, + searchQueryFlow: StateFlow, sortOrderFlow: StateFlow, ): MyAppsModel { val appUpdates = appUpdatesFlow.collectAsState().value val installedApps = installedAppsFlow.collectAsState().value + val searchQuery = searchQueryFlow.collectAsState().value.normalize() val sortOrder = sortOrderFlow.collectAsState().value val packageNames = appUpdates?.map { it.packageName } ?: emptyList() val collator = Collator.getInstance(Locale.getDefault()) + + val updates = if (searchQuery.isBlank()) appUpdates else appUpdates?.filter { + it.name.normalize().contains(searchQuery, ignoreCase = true) + } + val installed = if (searchQuery.isBlank()) installedApps else installedApps?.filter { + it.name.normalize().contains(searchQuery, ignoreCase = true) + } return MyAppsModel( appUpdates = when (sortOrder) { - AppListSortOrder.NAME -> appUpdates?.sortedWith { a1, a2 -> + AppListSortOrder.NAME -> updates?.sortedWith { a1, a2 -> + // storing collator.getCollationKey() and using that could be an optimization collator.compare(a1.name, a2.name) } - AppListSortOrder.LAST_UPDATED -> appUpdates?.sortedByDescending { it.update.added } + AppListSortOrder.LAST_UPDATED -> updates?.sortedByDescending { it.update.added } }, - installedApps = installedApps?.filter { + installedApps = installed?.filter { // filter out apps already in updates it.packageName !in packageNames }?.let { apps -> when (sortOrder) { AppListSortOrder.NAME -> apps.sortedWith { a1, a2 -> + // storing collator.getCollationKey() and using that could be an optimization collator.compare(a1.name, a2.name) } AppListSortOrder.LAST_UPDATED -> apps.sortedByDescending { it.lastUpdated } @@ -41,9 +53,3 @@ fun MyAppsPresenter( sortOrder = sortOrder, ) } - -data class MyAppsModel( - val appUpdates: List? = null, - val installedApps: List? = null, - val sortOrder: AppListSortOrder = AppListSortOrder.NAME, -) diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt index c4273594f..54ff6c936 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt @@ -40,14 +40,13 @@ class MyAppsViewModel @Inject constructor( private val repoManager: RepoManager, ) : AndroidViewModel(app) { - val updates = updatesManager.updates - val numUpdates = updatesManager.numUpdates + private val localeList = LocaleListCompat.getDefault() + private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + + private val updates = updatesManager.updates private val installedApps = MutableStateFlow?>(null) private var installedAppsLiveData = db.getAppDao().getInstalledAppListItems(application.packageManager) - private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME) - private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) - private val localeList = LocaleListCompat.getDefault() private val installedAppsObserver = Observer> { list -> installedApps.value = list.map { app -> InstalledAppItem( @@ -61,10 +60,13 @@ class MyAppsViewModel @Inject constructor( ) } } + private val searchQuery = savedStateHandle.getMutableStateFlow("query", "") + private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME) val myAppsModel: StateFlow = scope.launchMolecule(mode = ContextClock) { MyAppsPresenter( appUpdatesFlow = updates, installedAppsFlow = installedApps, + searchQueryFlow = searchQuery, sortOrderFlow = sortOrder, ) } @@ -77,6 +79,10 @@ class MyAppsViewModel @Inject constructor( installedAppsLiveData.removeObserver(installedAppsObserver) } + fun search(query: String) { + searchQuery.value = query + } + fun changeSortOrder(sort: AppListSortOrder) { sortOrder.value = sort } diff --git a/next/src/main/res/values/strings-next.xml b/next/src/main/res/values/strings-next.xml index a6d560c97..83556e90d 100644 --- a/next/src/main/res/values/strings-next.xml +++ b/next/src/main/res/values/strings-next.xml @@ -13,6 +13,8 @@ No app selected No repository selected + No apps installed.\n\nInstall apps and they will appear here. + New apps Recently updated All apps @@ -21,6 +23,7 @@ Search… No apps found.\n\nTry to use less search terms or add/enable more repositories. No matching apps.\n\nTry to use less search terms or remove filters. + No matching apps.\n\nTry to use less search terms. Sort by name Sort by latest