From 632243def1bfd34f92cfbd606ecc1ed706455b91 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 22 Apr 2025 16:08:33 -0300 Subject: [PATCH] improve search and filtering code --- basic/build.gradle.kts | 2 + .../java/org/fdroid/basic/MainActivity.kt | 15 ++- .../java/org/fdroid/basic/MainViewModel.kt | 96 +++++++++++++++++++ .../java/org/fdroid/basic/ui/main/Apps.kt | 84 ++++++++-------- .../java/org/fdroid/basic/ui/main/Main.kt | 33 ++++++- .../fdroid/basic/ui/main/apps/AppDetails.kt | 1 + .../org/fdroid/basic/ui/main/apps/AppItem.kt | 79 +++++++++++++++ .../org/fdroid/basic/ui/main/apps/AppList.kt | 87 ++++------------- .../basic/ui/main/apps/AppNavigationItem.kt | 1 + .../fdroid/basic/ui/main/apps/AppsFilter.kt | 38 ++++---- .../fdroid/basic/ui/main/apps/AppsSearch.kt | 49 ++++------ .../basic/ui/main/apps/FilterPresenter.kt | 49 ++++++++++ gradle/libs.versions.toml | 2 + 13 files changed, 374 insertions(+), 162 deletions(-) create mode 100644 basic/src/main/java/org/fdroid/basic/MainViewModel.kt create mode 100644 basic/src/main/java/org/fdroid/basic/ui/main/apps/AppItem.kt create mode 100644 basic/src/main/java/org/fdroid/basic/ui/main/apps/FilterPresenter.kt diff --git a/basic/build.gradle.kts b/basic/build.gradle.kts index 0df56bbb3..3427a441b 100644 --- a/basic/build.gradle.kts +++ b/basic/build.gradle.kts @@ -49,11 +49,13 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.navigation) implementation(libs.androidx.compose.material3.adaptive.navigation.suite.android) + implementation(libs.molecule.runtime) testImplementation(libs.junit) diff --git a/basic/src/main/java/org/fdroid/basic/MainActivity.kt b/basic/src/main/java/org/fdroid/basic/MainActivity.kt index 97e4d9c75..f08b9f7b1 100644 --- a/basic/src/main/java/org/fdroid/basic/MainActivity.kt +++ b/basic/src/main/java/org/fdroid/basic/MainActivity.kt @@ -4,14 +4,27 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.collectAsState +import androidx.lifecycle.viewmodel.compose.viewModel import org.fdroid.basic.ui.main.Main +import org.fdroid.basic.ui.main.Sort +import org.fdroid.basic.ui.main.apps.FilterInfo class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - Main() + val viewModel: MainViewModel = viewModel() + val filterInfo = object : FilterInfo { + override val model = viewModel.filterModel.collectAsState().value + override fun sortBy(sort: Sort) = viewModel.sortBy(sort) + override fun addCategory(category: String) = viewModel.addCategory(category) + override fun removeCategory(category: String) = viewModel.removeCategory(category) + override fun showOnlyInstalledApps(onlyInstalled: Boolean) = + viewModel.showOnlyInstalledApps(onlyInstalled) + } + Main(filterInfo) } } } diff --git a/basic/src/main/java/org/fdroid/basic/MainViewModel.kt b/basic/src/main/java/org/fdroid/basic/MainViewModel.kt new file mode 100644 index 000000000..b24c33366 --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/MainViewModel.kt @@ -0,0 +1,96 @@ +package org.fdroid.basic + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import app.cash.molecule.AndroidUiDispatcher +import app.cash.molecule.RecompositionMode.ContextClock +import app.cash.molecule.launchMolecule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import org.fdroid.basic.ui.main.NUM_ITEMS +import org.fdroid.basic.ui.main.Sort +import org.fdroid.basic.ui.main.apps.AppNavigationItem +import org.fdroid.basic.ui.main.apps.FilterModel +import org.fdroid.basic.ui.main.apps.FilterPresenter + +class MainViewModel(app: Application) : AndroidViewModel(app) { + + private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + val categories = listOf( + app.getString(R.string.category_Time), + app.getString(R.string.category_Games), + app.getString(R.string.category_Money), + app.getString(R.string.category_Reading), + app.getString(R.string.category_Theming), + app.getString(R.string.category_Connectivity), + app.getString(R.string.category_Internet), + app.getString(R.string.category_Navigation), + app.getString(R.string.category_Multimedia), + app.getString(R.string.category_Phone_SMS), + app.getString(R.string.category_Science_Education), + app.getString(R.string.category_Security), + app.getString(R.string.category_Sports_Health), + app.getString(R.string.category_System), + app.getString(R.string.category_Writing), + ) + + val initialApps = buildList { + repeat(NUM_ITEMS) { i -> + val category = categories.getOrElse(i) { categories.random() } + val navItem = AppNavigationItem( + packageName = "$i", + name = "App $i", + summary = "Summary of the app • $category", + isNew = i > NUM_ITEMS - 4, + ) + add(navItem) + } + } + + private val _onlyInstalledApps = MutableStateFlow(false) + val onlyInstalledApps = _onlyInstalledApps.asStateFlow() + private val _sortBy = MutableStateFlow(Sort.NAME) + val sortBy = _sortBy.asStateFlow() + private val _addedCategories = MutableStateFlow>(emptyList()) + val addedCategories = _addedCategories.asStateFlow>() + + val filterModel: StateFlow = scope.launchMolecule(mode = ContextClock) { + FilterPresenter( + appsFlow = flow { emit(initialApps) }, + onlyInstalledAppsFlow = onlyInstalledApps, + sortByFlow = sortBy, + allCategories = categories, + addedCategoriesFlow = addedCategories, + ) + } + + fun sortBy(sort: Sort) { + _sortBy.update { sort } + } + + fun addCategory(category: String) { + _addedCategories.update { + addedCategories.value.toMutableList().apply { + add(category) + } + } + } + + fun removeCategory(category: String) { + _addedCategories.update { + addedCategories.value.toMutableList().apply { + remove(category) + } + } + } + + fun showOnlyInstalledApps(onlyInstalled: Boolean) { + _onlyInstalledApps.update { onlyInstalled } + } + +} diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/Apps.kt b/basic/src/main/java/org/fdroid/basic/ui/main/Apps.kt index 730710ccb..6fa7bd9f3 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/Apps.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/Apps.kt @@ -3,6 +3,8 @@ package org.fdroid.basic.ui.main import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold @@ -18,16 +20,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch -import org.fdroid.basic.R import org.fdroid.basic.ui.main.apps.AppDetails import org.fdroid.basic.ui.main.apps.AppList import org.fdroid.basic.ui.main.apps.AppNavigationItem import org.fdroid.basic.ui.main.apps.AppsFilter import org.fdroid.basic.ui.main.apps.AppsSearch +import org.fdroid.basic.ui.main.apps.FilterInfo +import org.fdroid.basic.ui.main.apps.FilterModel import org.fdroid.fdroid.ui.theme.FDroidContent enum class Sort { @@ -39,7 +42,11 @@ const val NUM_ITEMS = 42 @Composable @OptIn(ExperimentalMaterial3AdaptiveApi::class) -fun Apps(modifier: Modifier) { +fun Apps( + apps: List, + filterInfo: FilterInfo, + modifier: Modifier, +) { val navigator = rememberListDetailPaneScaffoldNavigator() val scope = rememberCoroutineScope() BackHandler(enabled = navigator.canNavigateBack()) { @@ -56,51 +63,24 @@ fun Apps(modifier: Modifier) { Column( modifier.fillMaxSize() ) { - var filterExpanded by rememberSaveable { mutableStateOf(true) } - var sortBy by rememberSaveable { mutableStateOf(Sort.NAME) } - var onlyInstalledApps by rememberSaveable { mutableStateOf(false) } - val addedCategories = remember { mutableStateListOf() } + var filterExpanded by rememberSaveable { mutableStateOf(false) } val addedRepos = remember { mutableStateListOf() } - val categories = listOf( - stringResource(R.string.category_Time), - stringResource(R.string.category_Games), - stringResource(R.string.category_Money), - stringResource(R.string.category_Reading), - stringResource(R.string.category_Theming), - stringResource(R.string.category_Connectivity), - stringResource(R.string.category_Internet), - stringResource(R.string.category_Navigation), - stringResource(R.string.category_Multimedia), - stringResource(R.string.category_Phone_SMS), - stringResource(R.string.category_Science_Education), - stringResource(R.string.category_Security), - stringResource(R.string.category_Sports_Health), - stringResource(R.string.category_System), - stringResource(R.string.category_Writing), - ) + val showFilterBadge = addedRepos.isNotEmpty() || + filterInfo.model.addedCategories.isNotEmpty() || + filterInfo.model.onlyInstalledApps AppsSearch( - onlyInstalledApps = onlyInstalledApps, - addedCategories = addedCategories, - addedRepos = addedRepos, + showFilterBadge = showFilterBadge, toggleFilter = { filterExpanded = !filterExpanded }, - ) + ) { + scope.launch { navigator.navigateTo(Detail, it) } + } AppsFilter( filterExpanded = filterExpanded, - sortBy = sortBy, - onlyInstalledApps = onlyInstalledApps, - addedCategories = addedCategories, + filter = filterInfo, addedRepos = addedRepos, - categories = categories, - onSortByChanged = { sortBy = it }, - toggleOnlyInstalledApps = { - onlyInstalledApps = !onlyInstalledApps - }, ) AppList( - onlyInstalledApps = onlyInstalledApps, - sortBy = sortBy, - addedCategories = addedCategories, - categories = categories, + apps = apps, currentItem = if (isDetailVisible) { navigator.currentDestination?.contentKey } else { @@ -118,7 +98,7 @@ fun Apps(modifier: Modifier) { AppDetails( appItem = it, ) - } + } ?: Text("No app selected", modifier = Modifier.padding(16.dp)) } }, ) @@ -129,6 +109,26 @@ fun Apps(modifier: Modifier) { @Composable fun AppsPreview() { FDroidContent { - Apps(Modifier) + val apps = listOf( + AppNavigationItem("", "foo", "bar", false), + AppNavigationItem("", "foo", "bar", false), + AppNavigationItem("", "foo", "bar", false), + ) + val filterInfo = object : FilterInfo { + override val model = FilterModel( + isLoading = false, + apps = apps, + onlyInstalledApps = false, + sortBy = Sort.NAME, + allCategories = listOf("foo", "bar"), + addedCategories = emptyList(), + ) + + override fun sortBy(sort: Sort) {} + override fun addCategory(category: String) {} + override fun removeCategory(category: String) {} + override fun showOnlyInstalledApps(onlyInstalled: Boolean) {} + } + Apps(apps, filterInfo, Modifier) } } diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt b/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt index 4f5673b99..a87074818 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt @@ -27,6 +27,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.window.core.layout.WindowWidthSizeClass import org.fdroid.basic.R +import org.fdroid.basic.ui.main.apps.AppNavigationItem +import org.fdroid.basic.ui.main.apps.FilterInfo +import org.fdroid.basic.ui.main.apps.FilterModel import org.fdroid.fdroid.ui.theme.FDroidContent enum class AppDestinations( @@ -38,7 +41,7 @@ enum class AppDestinations( } @Composable -fun Main() { +fun Main(filterInfo: FilterInfo) { var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.APPS) } FDroidContent { val adaptiveInfo = currentWindowAdaptiveInfo() @@ -80,7 +83,11 @@ fun Main() { } } ) { - if (currentDestination == AppDestinations.APPS) Apps(Modifier) + if (currentDestination == AppDestinations.APPS) Apps( + apps = filterInfo.model.apps, + filterInfo = filterInfo, + modifier = Modifier, + ) else Text( text = "TODO", modifier = Modifier.safeDrawingPadding(), @@ -93,5 +100,25 @@ fun Main() { @PreviewScreenSizes @Composable fun MainPreview() { - Main() + val apps = listOf( + AppNavigationItem("", "foo", "bar", false), + AppNavigationItem("", "foo", "bar", false), + AppNavigationItem("", "foo", "bar", false), + ) + val filterInfo = object : FilterInfo { + override val model = FilterModel( + isLoading = false, + apps = apps, + onlyInstalledApps = false, + sortBy = Sort.NAME, + allCategories = listOf("foo", "bar"), + addedCategories = emptyList(), + ) + + override fun sortBy(sort: Sort) {} + override fun addCategory(category: String) {} + override fun removeCategory(category: String) {} + override fun showOnlyInstalledApps(onlyInstalled: Boolean) {} + } + Main(filterInfo) } diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppDetails.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppDetails.kt index 1671dc9ac..690dba9ff 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppDetails.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppDetails.kt @@ -49,6 +49,7 @@ fun AppDetailsPreview() { packageName = "foo", name = "bar", summary = "This is a nice app!", + isNew = false, ) AppDetails(item) } diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppItem.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppItem.kt new file mode 100644 index 000000000..2973c48d1 --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppItem.kt @@ -0,0 +1,79 @@ +package org.fdroid.basic.ui.main.apps + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.NewReleases +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.fdroid.ui.theme.FDroidContent + +@Composable +fun AppItem( + name: String, + summary: String, + isNew: Boolean, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(name) }, + supportingContent = { Text(summary) }, + leadingContent = { + BadgedBox(badge = { + if (isNew) Icon( + imageVector = Icons.Filled.NewReleases, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, modifier = Modifier.size(24.dp), + ) + }) { + Icon( + Icons.Filled.Android, + tint = MaterialTheme.colorScheme.secondary, + contentDescription = null, + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + modifier = modifier, + ) +} + +@Preview +@Composable +fun AppItemPreview() { + FDroidContent { + AppItem("This is app 1", "It has summary 2", false, false) + } +} + +@Preview +@Composable +fun AppItemPreviewNew() { + FDroidContent { + AppItem("This is app 1", "It has summary 2", true, false) + } +} + +@Preview +@Composable +fun AppItemPreviewSelected() { + FDroidContent { + AppItem("This is app 1", "It has summary 2", false, true) + } +} diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppList.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppList.kt index 3d74d8ab4..269623fe1 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppList.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppList.kt @@ -1,6 +1,5 @@ package org.fdroid.basic.ui.main.apps -import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -8,34 +7,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.NewReleases -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import org.fdroid.basic.ui.main.NUM_ITEMS -import org.fdroid.basic.ui.main.Sort @Composable fun AppList( - onlyInstalledApps: Boolean, - sortBy: Sort, - addedCategories: List, - categories: List, + apps: List, currentItem: AppNavigationItem?, onItemClick: (AppNavigationItem) -> Unit, ) { @@ -47,62 +31,23 @@ fun AppList( else Modifier.selectableGroup() ), ) { - repeat(NUM_ITEMS) { idx -> - if (onlyInstalledApps && idx % 2 > 0) return@repeat - val i = if (sortBy == Sort.NAME) idx else NUM_ITEMS - idx - val category = categories.getOrElse(i) { categories.random() } - if (addedCategories.isNotEmpty() && category !in addedCategories) return@repeat - item { - val navItem = AppNavigationItem( - packageName = "$i", - name = "App $i", - summary = "Summary of the app • $category", + items(apps) { navItem -> + val isSelected = currentItem?.packageName == navItem.packageName + val interactionModifier = if (currentItem == null) { + Modifier.clickable( + onClick = { onItemClick(navItem) } ) - val isSelected = currentItem?.packageName == navItem.packageName - val interactionModifier = if (currentItem == null) { - Modifier.clickable( - onClick = { onItemClick(navItem) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onItemClick(navItem) } - ) - } - ListItem( - headlineContent = { Text(navItem.name) }, - supportingContent = { Text(navItem.summary) }, - leadingContent = { - BadgedBox(badge = { - if (i <= 3) Icon( - imageVector = Icons.Filled.NewReleases, - tint = MaterialTheme.colorScheme.primary, - contentDescription = null, modifier = Modifier.size(24.dp), - ) - }) { - Icon( - Icons.Filled.Android, - tint = MaterialTheme.colorScheme.secondary, - contentDescription = null, - ) - } - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.surfaceVariant - } else { - Color.Transparent - } - ), - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 8.dp, - vertical = 4.dp - ) - .then(interactionModifier) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onItemClick(navItem) } ) } + val modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .then(interactionModifier) + AppItem(navItem.name, navItem.summary, navItem.isNew, isSelected, modifier) } item { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppNavigationItem.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppNavigationItem.kt index 7a7e9f0f9..0ce464ae0 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppNavigationItem.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppNavigationItem.kt @@ -8,4 +8,5 @@ class AppNavigationItem( val packageName: String, val name: String, val summary: String, + val isNew: Boolean, ): Parcelable diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsFilter.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsFilter.kt index 267c8c29a..01286ab79 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsFilter.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsFilter.kt @@ -30,16 +30,20 @@ import androidx.compose.ui.unit.dp import org.fdroid.basic.R import org.fdroid.basic.ui.main.Sort +interface FilterInfo { + val model: FilterModel + + fun sortBy(sort: Sort) + fun addCategory(category: String) + fun removeCategory(category: String) + fun showOnlyInstalledApps(onlyInstalled: Boolean) +} + @Composable fun ColumnScope.AppsFilter( filterExpanded: Boolean, - sortBy: Sort, - onlyInstalledApps: Boolean, - addedCategories: MutableList, + filter: FilterInfo, addedRepos: MutableList, - categories: List, - onSortByChanged: (Sort) -> Unit, - toggleOnlyInstalledApps: () -> Unit, ) { AnimatedVisibility(filterExpanded) { FlowRow( @@ -50,7 +54,7 @@ fun ColumnScope.AppsFilter( var sortByMenuExpanded by remember { mutableStateOf(false) } var repoMenuExpanded by remember { mutableStateOf(false) } var categoryMenuExpanded by remember { mutableStateOf(false) } - addedCategories.forEach { category -> + filter.model.addedCategories.forEach { category -> FilterChip( selected = true, trailingIcon = { @@ -64,7 +68,7 @@ fun ColumnScope.AppsFilter( Text(category) }, onClick = { - addedCategories.remove(category) + filter.removeCategory(category) } ) } @@ -89,7 +93,7 @@ fun ColumnScope.AppsFilter( FilterChip( selected = false, leadingIcon = { - val vector = when (sortBy) { + val vector = when (filter.model.sortBy) { Sort.NAME -> Icons.Filled.SortByAlpha Sort.LATEST -> Icons.Filled.AccessTime } @@ -99,7 +103,7 @@ fun ColumnScope.AppsFilter( Icon(Icons.Filled.ArrowDropDown, null) }, label = { - val s = when (sortBy) { + val s = when (filter.model.sortBy) { Sort.NAME -> "Sort by name" Sort.LATEST -> "Sort by latest" } @@ -114,7 +118,7 @@ fun ColumnScope.AppsFilter( Icon(Icons.Filled.SortByAlpha, null) }, onClick = { - onSortByChanged(Sort.NAME) + filter.sortBy(Sort.NAME) sortByMenuExpanded = false }, ) @@ -124,7 +128,7 @@ fun ColumnScope.AppsFilter( Icon(Icons.Filled.AccessTime, null) }, onClick = { - onSortByChanged(Sort.LATEST) + filter.sortBy(Sort.LATEST) sortByMenuExpanded = false }, ) @@ -133,8 +137,8 @@ fun ColumnScope.AppsFilter( onClick = { sortByMenuExpanded = !sortByMenuExpanded }, ) FilterChip( - selected = onlyInstalledApps, - leadingIcon = if (onlyInstalledApps) { + selected = filter.model.onlyInstalledApps, + leadingIcon = if (filter.model.onlyInstalledApps) { { Icon( imageVector = Icons.Filled.Done, @@ -144,7 +148,7 @@ fun ColumnScope.AppsFilter( } } else null, label = { Text(stringResource(R.string.app_installed)) }, - onClick = toggleOnlyInstalledApps, + onClick = { filter.showOnlyInstalledApps(!filter.model.onlyInstalledApps) }, ) FilterChip( selected = false, @@ -161,11 +165,11 @@ fun ColumnScope.AppsFilter( expanded = categoryMenuExpanded, onDismissRequest = { categoryMenuExpanded = false }, ) { - categories.forEach { category -> + filter.model.allCategories.forEach { category -> DropdownMenuItem( text = { Text(category) }, onClick = { - addedCategories.add(category) + filter.addCategory(category) categoryMenuExpanded = false }, ) diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsSearch.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsSearch.kt index 78cfdeaff..5c7c84b32 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsSearch.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/AppsSearch.kt @@ -8,33 +8,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.ExpandedFullScreenSearchBar +import androidx.compose.material3.ExpandedDockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.Text import androidx.compose.material3.TopSearchBar import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @Composable @OptIn(ExperimentalMaterial3Api::class) fun AppsSearch( - onlyInstalledApps: Boolean, - addedCategories: List, - addedRepos: List, + showFilterBadge: Boolean, toggleFilter: () -> Unit, + onItemClick: (AppNavigationItem) -> Unit, ) { val textFieldState = rememberTextFieldState() val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() @@ -45,8 +36,7 @@ fun AppsSearch( searchBarState = searchBarState, textFieldState = textFieldState, toggleFilter = toggleFilter, - showFilterBadge = addedRepos.isNotEmpty() || addedCategories.isNotEmpty() || - onlyInstalledApps, + showFilterBadge = showFilterBadge, ) } TopSearchBar( @@ -56,26 +46,29 @@ fun AppsSearch( windowInsets = WindowInsets.systemBars, inputField = inputField, ) - ExpandedFullScreenSearchBar( + ExpandedDockedSearchBar( state = searchBarState, inputField = inputField, ) { Column(Modifier.verticalScroll(rememberScrollState())) { - repeat(4) { idx -> - val resultText = "Suggestion $idx" - ListItem(headlineContent = { Text(resultText) }, - supportingContent = { Text("Additional info") }, - leadingContent = { - Icon( - Icons.Filled.Star, - contentDescription = null - ) - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), + repeat(4) { i -> + val navItem = AppNavigationItem( + packageName = "$i", + name = "App $i", + summary = "Summary of the app", + isNew = false, + ) + AppItem( + name = navItem.name, + summary = navItem.summary, + isNew = navItem.isNew, + isSelected = false, modifier = Modifier .clickable { - textFieldState.setTextAndPlaceCursorAtEnd(resultText) - scope.launch { searchBarState.animateToCollapsed() } + scope.launch { + searchBarState.animateToCollapsed() + onItemClick(navItem) + } } .fillMaxWidth() .padding( diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/FilterPresenter.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/FilterPresenter.kt new file mode 100644 index 000000000..67af918df --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/FilterPresenter.kt @@ -0,0 +1,49 @@ +package org.fdroid.basic.ui.main.apps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.fdroid.basic.ui.main.Sort + +@Composable +fun FilterPresenter( + appsFlow: Flow>, + onlyInstalledAppsFlow: StateFlow, + sortByFlow: StateFlow, + allCategories: List, + addedCategoriesFlow: StateFlow>, +): FilterModel { + val apps = appsFlow.collectAsState(null).value + val onlyInstalledApps = onlyInstalledAppsFlow.collectAsState().value + val sortBy = sortByFlow.collectAsState().value + val addedCategories = addedCategoriesFlow.collectAsState().value + + val newApps = apps?.filter { app -> + if (onlyInstalledApps) app.packageName.toInt() % 2 > 0 else true + }?.filter { app -> + addedCategories.isEmpty() || addedCategories.any { app.summary.contains(it) } + } ?: emptyList() + + return FilterModel( + isLoading = apps == null, + apps = if (sortBy == Sort.NAME) { + newApps.sortedBy { it.packageName.toInt() } + } else { + newApps.sortedByDescending { it.packageName.toInt() } + }, + onlyInstalledApps = onlyInstalledApps, + sortBy = sortBy, + allCategories = allCategories, + addedCategories = addedCategories, + ) +} + +data class FilterModel( + val isLoading: Boolean, + val apps: List, + val onlyInstalledApps: Boolean, + val sortBy: Sort, + val allCategories: List, + val addedCategories: List, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c4fa4cea..1de62c88f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ okhttp = "4.12.0" room = "2.8.3" glide = "5.0.5" glideCompose = "1.0.0-beta08" +molecule = "2.2.0" androidxCoreKtx = "1.17.0" androidxAppcompat = "1.7.1" @@ -120,6 +121,7 @@ glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glideCompose" } glide-annotations = { module = "com.github.bumptech.glide:annotations", version.ref = "glide" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } guardianproject-netcipher = { module = "info.guardianproject.netcipher:netcipher", version.ref = "guardianprojectNetcipher" } guardianproject-panic = { module = "info.guardianproject.panic:panic", version.ref = "guardianprojectPanic" } nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }