feat(drawer): implement app reordering in folder settings (#6173)

* feat(drawer): implement app reordering in folder settings

- Add drag-and-drop reordering for apps within App Drawer folders
- Split folder editing UI into "Selected apps" and "Add apps" sections
- Add "remove" button (cross icon) to selected apps
- Update FolderService

* style: Fix spotless check violations in SelectAppsForDrawerFolder

Add missing trailing commas to comply with Kotlin coding style
This commit is contained in:
Pavel Volkov
2025-12-15 06:08:10 +03:00
committed by GitHub
parent c79214206f
commit c6e0227ab0
6 changed files with 189 additions and 244 deletions

View File

@@ -125,6 +125,8 @@
<string name="caddy">Caddy</string>
<string name="app_drawer_folder">App drawer folders</string>
<string name="selected_apps">Selected apps</string>
<string name="add_apps">Add apps</string>
<string name="app_drawer_folder_settings">Drawer folder</string>
<string name="add_folder">Create folder</string>
<string name="edit_folder">Edit folder</string>

View File

@@ -60,8 +60,12 @@ class FolderViewModel(
fun updateFolderItems(id: Int, title: String, appInfo: List<AppInfo>) {
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) {

View File

@@ -38,8 +38,8 @@ class FolderService(val context: Context) : SafeCloseable {
suspend fun updateFolderWithItems(folderInfoId: Int, title: String, appInfos: List<AppInfo>) = 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 ->

View File

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

View File

@@ -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 <T> DraggablePreferenceGroup(
defaultList: List<T>,
onOrderChange: (List<T>) -> Unit,
modifier: Modifier = Modifier,
onSettle: ((List<T>) -> Unit)? = null,
itemContent: @Composable ReorderableScope.(
item: T,
index: Int,
@@ -62,7 +64,14 @@ fun <T> 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 <T> 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 <T> 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 <T> 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)
}
}
}
}

View File

@@ -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<String>()) }
var selectedAppsInFolder by remember { mutableStateOf(setOf<ItemInfo>()) }
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<App>() }
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<App>(
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<AppInfo>().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<App>,
filteredList: Set<ItemInfo>,
onUpdateList: (Set<AppInfo>) -> 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<ItemInfo>,
context: Context,
onSetChange: (Set<ItemInfo>) -> 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)
}