diff --git a/lawnchair/res/values/strings.xml b/lawnchair/res/values/strings.xml index 5369684f21..cc7f515900 100644 --- a/lawnchair/res/values/strings.xml +++ b/lawnchair/res/values/strings.xml @@ -125,6 +125,8 @@ Caddy App drawer folders + Selected apps + Add apps Drawer folder Create folder Edit folder diff --git a/lawnchair/src/app/lawnchair/data/folder/model/FolderViewModel.kt b/lawnchair/src/app/lawnchair/data/folder/model/FolderViewModel.kt index c9d9f11fd2..a12c735986 100644 --- a/lawnchair/src/app/lawnchair/data/folder/model/FolderViewModel.kt +++ b/lawnchair/src/app/lawnchair/data/folder/model/FolderViewModel.kt @@ -60,8 +60,12 @@ class FolderViewModel( fun updateFolderItems(id: Int, title: String, appInfo: List) { viewModelScope.launch { repository.updateFolderWithItems(id, title, appInfo) + // Update the local state flow so UI can observe changes without full reload if needed, + // though for now we just rely on reloadGrid to refresh the launcher. + // We call reloadGrid *after* the DB update is complete. + _folderInfo.value = repository.getFolderInfo(id, true) + reloadHelper.reloadGrid() } - reloadHelper.reloadGrid() } fun createFolder(folderInfo: FolderInfo) { diff --git a/lawnchair/src/app/lawnchair/data/folder/service/FolderService.kt b/lawnchair/src/app/lawnchair/data/folder/service/FolderService.kt index 9305db4baf..c82b9b8194 100644 --- a/lawnchair/src/app/lawnchair/data/folder/service/FolderService.kt +++ b/lawnchair/src/app/lawnchair/data/folder/service/FolderService.kt @@ -38,8 +38,8 @@ class FolderService(val context: Context) : SafeCloseable { suspend fun updateFolderWithItems(folderInfoId: Int, title: String, appInfos: List) = withContext(Dispatchers.IO) { folderDao.insertFolderWithItems( FolderInfoEntity(id = folderInfoId, title = title), - appInfos.map { - it.toEntity(folderInfoId) + appInfos.mapIndexed { index, appInfo -> + appInfo.toEntity(folderInfoId).copy(rank = index) }.toList(), ) } @@ -70,7 +70,7 @@ class FolderService(val context: Context) : SafeCloseable { title = folderWithItems.folder.title } - folderWithItems.items.forEach { itemEntity -> + folderWithItems.items.sortedBy { it.rank }.forEach { itemEntity -> // Consider caching toItemInfo results if componentKey lookups are slow // and items don't change frequently without folder data changing toItemInfo(itemEntity.componentKey)?.let { appInfo -> diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/AppItem.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/AppItem.kt index 0d94bdaad9..0bd50a582c 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/components/AppItem.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/AppItem.kt @@ -36,34 +36,6 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate import app.lawnchair.util.App import com.android.launcher3.model.data.AppInfo -@Composable -fun AppItem( - app: App, - onClick: (app: App) -> Unit, - widget: (@Composable () -> Unit)? = null, -) { - AppItem( - label = app.label, - icon = app.icon, - onClick = { onClick(app) }, - widget = widget, - ) -} - -@Composable -fun AppItem( - appInfo: AppInfo, - onClick: (appInfo: AppInfo) -> Unit, - widget: (@Composable () -> Unit)? = null, -) { - AppItem( - label = appInfo.title.toString(), - icon = appInfo.bitmap.icon, - onClick = { onClick(appInfo) }, - widget = widget, - ) -} - @Composable fun AppItem( label: String, @@ -71,11 +43,13 @@ fun AppItem( onClick: () -> Unit, modifier: Modifier = Modifier, widget: (@Composable () -> Unit)? = null, + endWidget: (@Composable () -> Unit)? = null, ) { AppItemLayout( modifier = modifier .clickable(onClick = onClick), widget = widget, + endWidget = endWidget, icon = { Image( bitmap = icon.asImageBitmap(), @@ -87,6 +61,38 @@ fun AppItem( ) } +@Composable +fun AppItem( + app: App, + onClick: (app: App) -> Unit, + widget: (@Composable () -> Unit)? = null, + endWidget: (@Composable () -> Unit)? = null, +) { + AppItem( + label = app.label, + icon = app.icon, + onClick = { onClick(app) }, + widget = widget, + endWidget = endWidget, + ) +} + +@Composable +fun AppItem( + appInfo: AppInfo, + onClick: (appInfo: AppInfo) -> Unit, + widget: (@Composable () -> Unit)? = null, + endWidget: (@Composable () -> Unit)? = null, +) { + AppItem( + label = appInfo.title.toString(), + icon = appInfo.bitmap.icon, + onClick = { onClick(appInfo) }, + widget = widget, + endWidget = endWidget, + ) +} + @Composable fun AppItemPlaceholder( modifier: Modifier = Modifier, @@ -125,6 +131,7 @@ private fun AppItemLayout( title: @Composable () -> Unit, modifier: Modifier = Modifier, widget: (@Composable () -> Unit)? = null, + endWidget: (@Composable () -> Unit)? = null, ) { PreferenceTemplate( title = title, @@ -136,6 +143,7 @@ private fun AppItemLayout( } icon() }, + endWidget = endWidget, verticalPadding = 12.dp, ) } diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/DraggablePreference.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/DraggablePreference.kt index 36cf72e93a..fcb4db60d1 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/components/DraggablePreference.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/DraggablePreference.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -55,6 +56,7 @@ fun DraggablePreferenceGroup( defaultList: List, onOrderChange: (List) -> Unit, modifier: Modifier = Modifier, + onSettle: ((List) -> Unit)? = null, itemContent: @Composable ReorderableScope.( item: T, index: Int, @@ -62,7 +64,14 @@ fun DraggablePreferenceGroup( onDraggingChange: (Boolean) -> Unit, ) -> Unit, ) { - var localItems = items + var localItems by remember { mutableStateOf(items) } + + LaunchedEffect(items) { + if (localItems != items) { + localItems = items + } + } + var isAnyDragging by remember { mutableStateOf(false) } val color by animateColorAsState( @@ -85,12 +94,15 @@ fun DraggablePreferenceGroup( modifier = Modifier, list = localItems, onSettle = { fromIndex, toIndex -> - localItems = localItems.toMutableList().apply { + val newItems = localItems.toMutableList().apply { add(toIndex, removeAt(fromIndex)) - }.toList().also { newItems -> - onOrderChange(newItems) - isAnyDragging = false + }.toList() + localItems = newItems + onOrderChange(newItems) + if (onSettle != null) { + onSettle(newItems) } + isAnyDragging = false }, onMove = { isAnyDragging = true @@ -107,8 +119,20 @@ fun DraggablePreferenceGroup( .a11yDrag( index = index, items = items, - onMoveUp = { localItems = it }, - onMoveDown = { localItems = it }, + onMoveUp = { + localItems = it + onOrderChange(it) + if (onSettle != null) { + onSettle(it) + } + }, + onMoveDown = { + localItems = it + onOrderChange(it) + if (onSettle != null) { + onSettle(it) + } + }, ), ) { itemContent( @@ -132,7 +156,11 @@ fun DraggablePreferenceGroup( ExpandAndShrink(visible = localItems != defaultList) { PreferenceGroup { ClickablePreference(label = stringResource(id = R.string.action_reset)) { - onOrderChange(defaultList) + val resetList = defaultList + onOrderChange(resetList) + if (onSettle != null) { + onSettle(resetList) + } } } } diff --git a/lawnchair/src/app/lawnchair/ui/preferences/destinations/SelectAppsForDrawerFolder.kt b/lawnchair/src/app/lawnchair/ui/preferences/destinations/SelectAppsForDrawerFolder.kt index 7d91f7c947..1b65f9ee60 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/destinations/SelectAppsForDrawerFolder.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/destinations/SelectAppsForDrawerFolder.kt @@ -1,22 +1,20 @@ -package app.lawnchair.ui.preferences.destinations +package app.lawnchair.ui.preferences.destinations import android.content.Context import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -27,19 +25,19 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import app.lawnchair.data.folder.model.FolderViewModel -import app.lawnchair.ui.OverflowMenu import app.lawnchair.ui.preferences.LocalIsExpandedScreen import app.lawnchair.ui.preferences.components.AppItem import app.lawnchair.ui.preferences.components.AppItemPlaceholder -import app.lawnchair.ui.preferences.components.layout.PreferenceDivider -import app.lawnchair.ui.preferences.components.layout.PreferenceLazyColumn -import app.lawnchair.ui.preferences.components.layout.PreferenceScaffold +import app.lawnchair.ui.preferences.components.DragHandle +import app.lawnchair.ui.preferences.components.DraggablePreferenceGroup +import app.lawnchair.ui.preferences.components.layout.PreferenceLayoutLazyColumn import app.lawnchair.ui.preferences.components.layout.preferenceGroupItems import app.lawnchair.util.App import app.lawnchair.util.appsState import com.android.launcher3.R import com.android.launcher3.model.data.AppInfo import com.android.launcher3.model.data.ItemInfo +import com.android.launcher3.util.ComponentKey @Composable fun SelectAppsForDrawerFolder( @@ -57,223 +55,128 @@ fun SelectAppsForDrawerFolder( val folders by viewModel.folders.collectAsStateWithLifecycle() val folderInfo by viewModel.folderInfo.collectAsStateWithLifecycle() - var allFolderPackages by remember { mutableStateOf(emptySet()) } - var selectedAppsInFolder by remember { mutableStateOf(setOf()) } - var filterNonUniqueItems by remember { mutableStateOf(true) } - - LaunchedEffect(folders) { - allFolderPackages = folders.flatMap { it.getContents() } - .mapNotNull { it.targetPackage } - .toSet() - } - - LaunchedEffect(folderInfo) { - selectedAppsInFolder = folderInfo?.getContents()?.toMutableSet() ?: emptySet() - } + val selectedAppsInFolder = remember { mutableStateListOf() } LaunchedEffect(folderInfoId) { viewModel.setFolderInfo(folderInfoId, false) } val apps by appsState() - val filteredApps = apps.filter { app -> - if (filterNonUniqueItems) { - !allFolderPackages.contains(app.key.componentName.packageName) || selectedAppsInFolder.map { it.targetPackage }.contains(app.key.componentName.packageName) - } else { - true + var isInitialLoad by remember { mutableStateOf(true) } + + LaunchedEffect(folderInfo, apps) { + if (isInitialLoad && folderInfo != null && apps.isNotEmpty()) { + val currentContent = folderInfo!!.getContents() + val orderedApps = currentContent.sortedBy { it.rank }.mapNotNull { item -> + val key = ComponentKey(item.targetComponent, item.user) + apps.find { it.key == key } + } + selectedAppsInFolder.clear() + selectedAppsInFolder.addAll(orderedApps) + isInitialLoad = false } } val loading = folderInfo == null && apps.isEmpty() - PreferenceScaffold( + PreferenceLayoutLazyColumn( label = if (loading) { stringResource(R.string.loading) } else { stringResource(R.string.x_with_y_count, folderInfo?.title.toString(), selectedAppsInFolder.size) }, modifier = modifier, - actions = { - if (!loading) { - ListSortingOptions( - originalList = apps, - filteredList = selectedAppsInFolder, - onUpdateList = { newSet -> - selectedAppsInFolder = newSet + isExpandedScreen = LocalIsExpandedScreen.current, + ) { + if (loading) { + preferenceGroupItems( + count = 20, + isFirstChild = true, + dividerStartIndent = 40.dp, + ) { + AppItemPlaceholder() + } + } else { + item { + if (selectedAppsInFolder.isNotEmpty()) { + DraggablePreferenceGroup( + label = stringResource(R.string.selected_apps), + items = selectedAppsInFolder, + defaultList = selectedAppsInFolder, + onOrderChange = { newOrder -> + selectedAppsInFolder.clear() + selectedAppsInFolder.addAll(newOrder) + }, + onSettle = { newOrder -> + // Update the database when drag settles + viewModel.updateFolderItems( + folderInfoId, + folderInfo?.title.toString(), + newOrder.map { it.toAppInfo(context) }, + ) + }, + ) { app, _, _, onDraggingChange -> + val interactionSource = remember { MutableInteractionSource() } + AppItem( + app = app, + onClick = { }, + widget = { + DragHandle( + scope = this, + interactionSource = interactionSource, + onDragStop = { + onDraggingChange(false) + }, + ) + }, + endWidget = { + IconButton(onClick = { + val newList = selectedAppsInFolder.toMutableList() + newList.remove(app) + selectedAppsInFolder.clear() + selectedAppsInFolder.addAll(newList) + viewModel.updateFolderItems( + folderInfoId, + folderInfo?.title.toString(), + newList.map { it.toAppInfo(context) }, + ) + }) { + Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.delete_label)) + } + }, + ) + } + } + } + + val unselectedApps = apps.filter { !selectedAppsInFolder.contains(it) } + + preferenceGroupItems( + items = unselectedApps, + heading = if (selectedAppsInFolder.isNotEmpty()) { + { stringResource(R.string.add_apps) } + } else { + null + }, + isFirstChild = selectedAppsInFolder.isEmpty(), + dividerStartIndent = 40.dp, + ) { _, app -> + AppItem( + app = app, + onClick = { + selectedAppsInFolder.add(app) viewModel.updateFolderItems( folderInfoId, folderInfo?.title.toString(), - newSet.toList(), + selectedAppsInFolder.map { it.toAppInfo(context) }, ) }, - filterUniqueItems = filterNonUniqueItems, - onToggleFilterUniqueItems = { - filterNonUniqueItems = it + widget = { + Icon(Icons.Rounded.Add, contentDescription = null) }, ) } - }, - isExpandedScreen = LocalIsExpandedScreen.current, - ) { - Crossfade(targetState = loading, label = "") { isLoading -> - if (isLoading) { - PreferenceLazyColumn(it, enabled = false, state = rememberLazyListState()) { - preferenceGroupItems( - count = 20, - isFirstChild = true, - dividerStartIndent = 40.dp, - ) { - AppItemPlaceholder { - Spacer(Modifier.width(24.dp)) - } - } - } - } else { - PreferenceLazyColumn(it, state = rememberLazyListState()) { - preferenceGroupItems( - filteredApps, - isFirstChild = true, - dividerStartIndent = 40.dp, - ) { _, app -> - key(app.toString()) { - AppItem( - app, - onClick = { - updateFolderItems( - app = it, - items = selectedAppsInFolder, - context = context, - onSetChange = { newSet -> - selectedAppsInFolder = newSet - - viewModel.updateFolderItems( - folderInfoId, - folderInfo?.title.toString(), - newSet.filterIsInstance().toList(), - ) - }, - ) - }, - ) { - Checkbox( - checked = selectedAppsInFolder.any { - val appInfo = it as? AppInfo - appInfo?.targetPackage == app.key.componentName.packageName && appInfo.user == app.key.user - }, - onCheckedChange = null, - ) - } - } - } - } - } } } } - -@Composable -private fun ListSortingOptions( - originalList: List, - filteredList: Set, - onUpdateList: (Set) -> Unit, - filterUniqueItems: Boolean, - onToggleFilterUniqueItems: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - OverflowMenu(modifier) { - val originalListPackageNames = originalList - .map { it.key.componentName.packageName } - DropdownMenuItem( - onClick = { - val inverseSelectionPackageNames = originalListPackageNames - .filter { items -> - !filteredList.map { it.targetPackage }.contains(items) - } - .toSet() - - val inverseSelection = originalList - .filter { - inverseSelectionPackageNames.contains(it.key.componentName.packageName) - } - .map { - it.toAppInfo(context) - } - .toSet() - - onUpdateList(inverseSelection) - hideMenu() - }, - text = { - Text(stringResource(R.string.inverse_selection)) - }, - ) - - val selectedAll = originalListPackageNames == filteredList.map { it.targetPackage } - DropdownMenuItem( - onClick = { - onUpdateList( - if (selectedAll) { - emptySet() - } else { - originalList.map { app -> - app.toAppInfo(context) - }.toSet() - }, - ) - hideMenu() - }, - text = { - Text( - stringResource(if (selectedAll) R.string.deselect_all else R.string.select_all), - ) - }, - ) - DropdownMenuItem( - onClick = { - onToggleFilterUniqueItems(!filterUniqueItems) - hideMenu() - }, - trailingIcon = { - if (filterUniqueItems) { - Icon(Icons.Rounded.Check, contentDescription = null) - } - }, - text = { - Text(stringResource(R.string.folders_filter_duplicates)) - }, - ) - PreferenceDivider(modifier = Modifier.padding(vertical = 8.dp)) - DropdownMenuItem( - onClick = { - onUpdateList( - emptySet(), - ) - }, - text = { - Text(stringResource(R.string.action_reset)) - }, - ) - } -} - -fun updateFolderItems( - app: App, - items: Set, - context: Context, - onSetChange: (Set) -> Unit, -) { - val newSet = items.toMutableSet().apply { - val isChecked = any { it is AppInfo && it.targetPackage == app.key.componentName.packageName && it.user == app.key.user } - if (isChecked) { - removeIf { it is AppInfo && it.targetPackage == app.key.componentName.packageName && it.user == app.key.user } - } else { - add( - app.toAppInfo(context), - ) - } - } - - onSetChange(newSet) -}