Add search filtering to My Apps

This commit is contained in:
Torsten Grote
2025-08-21 17:30:52 -03:00
parent c77da155e9
commit fa1573a1ec
6 changed files with 120 additions and 37 deletions

View File

@@ -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<MyAppsViewModel>()
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<NavigationKey.AppDetails>(

View File

@@ -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 = {},
)
}
}

View File

@@ -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<AppUpdateItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
)

View File

@@ -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<List<AppUpdateItem>?>,
installedAppsFlow: StateFlow<List<InstalledAppItem>?>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
): 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<AppUpdateItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
)

View File

@@ -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<List<InstalledAppItem>?>(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<AppListItem>> { list ->
installedApps.value = list.map { app ->
InstalledAppItem(
@@ -61,10 +60,13 @@ class MyAppsViewModel @Inject constructor(
)
}
}
private val searchQuery = savedStateHandle.getMutableStateFlow<String>("query", "")
private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME)
val myAppsModel: StateFlow<MyAppsModel> = 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
}

View File

@@ -13,6 +13,8 @@
<string name="no_app_selected">No app selected</string>
<string name="no_repository_selected">No repository selected</string>
<string name="my_apps_empty">No apps installed.\n\nInstall apps and they will appear here.</string>
<string name="app_list_new">New apps</string>
<string name="app_list_recently_updated">Recently updated</string>
<string name="app_list_all">All apps</string>
@@ -21,6 +23,7 @@
<string name="search_placeholder">Search…</string>
<string name="search_no_results">No apps found.\n\nTry to use less search terms or add/enable more repositories.</string>
<string name="search_filter_no_results">No matching apps.\n\nTry to use less search terms or remove filters.</string>
<string name="search_my_apps_no_results">No matching apps.\n\nTry to use less search terms.</string>
<string name="sort_by_name">Sort by name</string>
<string name="sort_by_latest">Sort by latest</string>
<!-- Used for filtering lists of apps by certain criteria -->