Feat: App list view (#1170)

* Feat: grid icon visibility settings and migration support

* fix: remove unnecessary padding

* Feat: Create List View Settings

* fix FavoritesPartProvider

* small fix

* fix favorites

* Remove useless datastore migration

* Change app list default value

* Revert migration 3

* Hide list icon preference if list is not enabled

* Use ListResults for app list

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
KorigamiK 2025-04-02 23:29:13 +05:30 committed by GitHub
parent b2cf7f5e5e
commit 2c2c88b93c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 674 additions and 498 deletions

View File

@ -13,7 +13,6 @@ import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.WidgetRepository import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.androidx.compose.inject import org.koin.androidx.compose.inject
@Composable @Composable

View File

@ -64,6 +64,7 @@ fun SearchColumn(
) { ) {
val columns = LocalGridSettings.current.columnCount val columns = LocalGridSettings.current.columnCount
val showList = LocalGridSettings.current.showList
val context = LocalContext.current val context = LocalContext.current
val viewModel: SearchVM = viewModel() val viewModel: SearchVM = viewModel()
@ -111,6 +112,7 @@ fun SearchColumn(
val expandedCategory: SearchCategory? by viewModel.expandedCategory val expandedCategory: SearchCategory? by viewModel.expandedCategory
var selectedAppProfileIndex: Int by remember(isSearchEmpty) { mutableIntStateOf(0) } var selectedAppProfileIndex: Int by remember(isSearchEmpty) { mutableIntStateOf(0) }
var selectedAppIndex: Int by remember(website) { mutableIntStateOf(-1) }
var selectedContactIndex: Int by remember(contacts) { mutableIntStateOf(-1) } var selectedContactIndex: Int by remember(contacts) { mutableIntStateOf(-1) }
var selectedFileIndex: Int by remember(files) { mutableIntStateOf(-1) } var selectedFileIndex: Int by remember(files) { mutableIntStateOf(-1) }
var selectedCalendarIndex: Int by remember(events) { mutableIntStateOf(-1) } var selectedCalendarIndex: Int by remember(events) { mutableIntStateOf(-1) }
@ -193,6 +195,9 @@ fun SearchColumn(
columns = columns, columns = columns,
reverse = reverse, reverse = reverse,
showProfileLockControls = hasProfilesPermission, showProfileLockControls = hasProfilesPermission,
showList = showList,
selectedIndex = selectedAppIndex,
onSelect = { selectedAppIndex = it },
) )
} else { } else {
AppResults( AppResults(
@ -202,7 +207,10 @@ fun SearchColumn(
selectedAppProfileIndex = it selectedAppProfileIndex = it
}, },
columns = columns, columns = columns,
reverse = reverse reverse = reverse,
showList = showList,
selectedIndex = selectedAppIndex,
onSelect = { selectedAppIndex = it },
) )
} }

View File

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@ -61,7 +62,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
@ -85,11 +85,15 @@ import kotlinx.coroutines.launch
fun AppItem( fun AppItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
app: Application, app: Application,
showDetails: Boolean,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${app.key}") val viewModel: SearchableItemVM = listItemViewModel(key = "search-${app.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels() val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
val badge by viewModel.badge.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
LaunchedEffect(app) { LaunchedEffect(app) {
viewModel.init(app, iconSize.toInt()) viewModel.init(app, iconSize.toInt())
} }
@ -97,386 +101,440 @@ fun AppItem(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( SharedTransitionLayout(modifier = modifier) {
modifier = modifier.verticalScroll(rememberScrollState()) AnimatedContent(showDetails) { showDetails ->
) { if (showDetails) {
Row { Column(
Column( modifier = Modifier.verticalScroll(rememberScrollState())
modifier = Modifier ) {
.weight(1f) Row {
.padding(16.dp) Column(
) { modifier = Modifier
Text( .weight(1f)
text = app.labelOverride ?: app.label, .padding(16.dp)
style = MaterialTheme.typography.titleMedium ) {
) Text(
text = app.labelOverride ?: app.label,
if (!app.isPrivate) { style = MaterialTheme.typography.titleMedium,
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
app.versionName?.let {
Text(
text = stringResource(R.string.app_info_version, it),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = app.componentName.packageName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 1.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
stringResource(R.string.profile_private_profile_state_locked),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
color = MaterialTheme.colorScheme.secondary,
)
}
}
val badge by viewModel.badge.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(16.dp),
badge = { badge },
icon = { icon },
)
}
val notifications by viewModel.notifications.collectAsState(emptyList())
AnimatedVisibility(notifications.isNotEmpty()) {
var showAllNotifications by remember { mutableStateOf(false) }
AnimatedContent(
showAllNotifications || notifications.size == 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small)
) { showAll ->
if (showAll) {
Column(
modifier = Modifier.animateContentSize()
) {
for ((i, not) in notifications.withIndex()) {
val icon =
remember(not.smallIcon) { not.smallIcon?.loadDrawable(context) }
if (not.title == null && not.text == null) continue
if (i > 0) {
HorizontalDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.clickable { .sharedBounds(
try { rememberSharedContentState("label"),
not.contentIntent?.sendWithBackgroundPermission(context) this@AnimatedContent,
} catch (e: PendingIntent.CanceledException) { ),
CrashReporter.logException(e) )
}
} if (!app.isPrivate) {
.padding(vertical = 4.dp)
) { val tags by viewModel.tags.collectAsState(emptyList())
Box( if (tags.isNotEmpty()) {
modifier = Modifier Text(
.padding(horizontal = 12.dp) modifier = Modifier.padding(top = 1.dp, bottom = 4.dp),
.clip(CircleShape) text = tags.joinToString(separator = " #", prefix = "#"),
.background(Color(not.color)) color = MaterialTheme.colorScheme.secondary,
.size(32.dp) style = MaterialTheme.typography.labelSmall
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = icon,
contentDescription = null
) )
} }
Column(
modifier = Modifier.weight(1f)
) {
if (not.title != null) {
Text(
not.title!!,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (not.text != null) {
Text( app.versionName?.let {
not.text!!, Text(
modifier = Modifier.padding(top = 2.dp), text = stringResource(R.string.app_info_version, it),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
overflow = TextOverflow.Ellipsis maxLines = 1,
) overflow = TextOverflow.Ellipsis
)
}
Text(
text = app.componentName.packageName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 1.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
stringResource(R.string.profile_private_profile_state_locked),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
color = MaterialTheme.colorScheme.secondary,
)
}
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(16.dp),
badge = { badge },
icon = { icon },
)
}
val notifications by viewModel.notifications.collectAsState(emptyList())
AnimatedVisibility(notifications.isNotEmpty()) {
var showAllNotifications by remember { mutableStateOf(false) }
AnimatedContent(
showAllNotifications || notifications.size == 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small)
) { showAll ->
if (showAll) {
Column(
modifier = Modifier.animateContentSize()
) {
for ((i, not) in notifications.withIndex()) {
val icon =
remember(not.smallIcon) {
not.smallIcon?.loadDrawable(
context
)
}
if (not.title == null && not.text == null) continue
if (i > 0) {
HorizontalDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
try {
not.contentIntent?.sendWithBackgroundPermission(
context
)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}
.padding(vertical = 4.dp)
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.clip(CircleShape)
.background(Color(not.color))
.size(32.dp)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = icon,
contentDescription = null
)
}
Column(
modifier = Modifier.weight(1f)
) {
if (not.title != null) {
Text(
not.title!!,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (not.text != null) {
Text(
not.text!!,
modifier = Modifier.padding(top = 2.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (not.isClearable) {
IconButton(
onClick = {
viewModel.clearNotification(not)
}
) {
Icon(Icons.Rounded.Clear, null)
}
}
}
} }
} }
if (not.isClearable) { } else {
Row(
modifier = Modifier
.clickable {
showAllNotifications = true
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Rounded.Notifications,
null,
modifier = Modifier.padding(horizontal = 16.dp)
)
Text(
pluralStringResource(
R.plurals.app_info_notifications,
notifications.size,
notifications.size
),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
Icons.AutoMirrored.Rounded.NavigateNext,
null,
modifier = Modifier.padding(horizontal = 12.dp)
)
}
}
}
}
val shortcuts by viewModel.children.collectAsState(emptyList())
if (shortcuts.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small)
) {
for ((i, shortcut) in shortcuts.withIndex()) {
val isPinned by remember(shortcut) {
viewModel.isChildPinned(
shortcut
)
}.collectAsState(
false
)
val iconSizePx = 32.dp.toPixels()
val icon by
remember {
viewModel.getChildIcon(
shortcut,
iconSizePx.toInt()
)
}.collectAsState(null)
if (i > 0) {
HorizontalDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
viewModel.launchChild(context, shortcut)
}
.padding(vertical = 4.dp)
) {
ShapedLauncherIcon(
size = 32.dp,
icon = { icon },
shape = CircleShape,
modifier = Modifier
.padding(horizontal = 12.dp)
.size(32.dp),
)
Text(
shortcut.labelOverride ?: shortcut.label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
IconButton( IconButton(
onClick = { onClick = {
viewModel.clearNotification(not) if (isPinned) {
viewModel.unpinChild(shortcut)
} else {
viewModel.pinChild(shortcut)
}
} }
) { ) {
Icon(Icons.Rounded.Clear, null) Icon(
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline,
stringResource(if (isPinned) R.string.menu_favorites_unpin else R.string.menu_favorites_pin),
)
} }
} }
} }
} }
} }
} else {
Row(
modifier = Modifier
.clickable {
showAllNotifications = true
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Rounded.Notifications,
null,
modifier = Modifier.padding(horizontal = 16.dp)
)
Text(
pluralStringResource(
R.plurals.app_info_notifications,
notifications.size,
notifications.size
),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
Icons.AutoMirrored.Rounded.NavigateNext,
null,
modifier = Modifier.padding(horizontal = 12.dp)
)
}
}
}
}
val shortcuts by viewModel.children.collectAsState(emptyList()) val toolbarActions = mutableListOf<ToolbarAction>()
if (shortcuts.isNotEmpty()) {
Column( if (LocalFavoritesEnabled.current) {
modifier = Modifier val isPinned by viewModel.isPinned.collectAsState(false)
.fillMaxWidth() val favAction = if (isPinned) {
.padding(horizontal = 16.dp) DefaultToolbarAction(
.padding(bottom = 12.dp) label = stringResource(R.string.menu_favorites_unpin),
.border( icon = Icons.Rounded.Star,
1.dp, action = {
MaterialTheme.colorScheme.outlineVariant, viewModel.unpin()
MaterialTheme.shapes.small }
) )
.clip(MaterialTheme.shapes.small) } else {
) { DefaultToolbarAction(
for ((i, shortcut) in shortcuts.withIndex()) { label = stringResource(R.string.menu_favorites_pin),
val isPinned by remember(shortcut) { viewModel.isChildPinned(shortcut) }.collectAsState( icon = Icons.Rounded.StarOutline,
false action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
if (!app.isPrivate) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
app.openAppDetails(context)
})
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_launch),
icon = Icons.AutoMirrored.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
)
) )
val iconSizePx = 32.dp.toPixels() val sheetManager = LocalBottomSheetManager.current
if (!app.isPrivate) {
val icon by toolbarActions.add(
remember { DefaultToolbarAction(
viewModel.getChildIcon( label = stringResource(R.string.menu_customize),
shortcut, icon = Icons.Rounded.Tune,
iconSizePx.toInt() action = { sheetManager.showCustomizeSearchableModal(app) }
) ))
}.collectAsState(null)
if (i > 0) {
HorizontalDivider()
} }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
viewModel.launchChild(context, shortcut)
}
.padding(vertical = 4.dp)
) {
ShapedLauncherIcon(
size = 32.dp,
icon = { icon },
shape = CircleShape,
modifier = Modifier
.padding(horizontal = 12.dp)
.size(32.dp),
)
Text( if (!app.isPrivate) {
shortcut.labelOverride ?: shortcut.label, val storeDetails = remember(app) { app.getStoreDetails(context) }
modifier = Modifier.weight(1f), val shareAction = if (storeDetails == null) {
style = MaterialTheme.typography.titleSmall, DefaultToolbarAction(
maxLines = 1, label = stringResource(R.string.menu_share),
overflow = TextOverflow.Ellipsis icon = Icons.Rounded.Share
) ) {
IconButton( scope.launch {
onClick = { app.shareApkFile(context)
if (isPinned) {
viewModel.unpinChild(shortcut)
} else {
viewModel.pinChild(shortcut)
} }
} }
) { } else {
Icon( SubmenuToolbarAction(
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline, label = stringResource(R.string.menu_share),
stringResource(if (isPinned) R.string.menu_favorites_unpin else R.string.menu_favorites_pin), icon = Icons.Rounded.Share,
children = listOf(
DefaultToolbarAction(
label = stringResource(
R.string.menu_share_store_link,
storeDetails.label
),
icon = Icons.Rounded.Link,
action = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT,
storeDetails.url
)
shareIntent.type = "text/plain"
context.startActivity(
Intent.createChooser(
shareIntent,
null
)
)
}
),
DefaultToolbarAction(
label = stringResource(R.string.menu_share_apk_file),
icon = Icons.Rounded.Android
) {
scope.launch {
app.shareApkFile(context)
}
}
)
) )
} }
toolbarActions.add(shareAction)
} }
} if (app.canUninstall) {
} toolbarActions.add(
} DefaultToolbarAction(
label = stringResource(R.string.menu_uninstall),
val toolbarActions = mutableListOf<ToolbarAction>() icon = Icons.Rounded.Delete,
) {
if (LocalFavoritesEnabled.current) { app.uninstall(context)
val isPinned by viewModel.isPinned.collectAsState(false) onBack()
val favAction = if (isPinned) { }
DefaultToolbarAction( )
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
} }
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
if (!app.isPrivate) { Toolbar(
toolbarActions.add( leftActions = listOf(
DefaultToolbarAction( DefaultToolbarAction(
label = stringResource(R.string.menu_app_info), label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.Info icon = Icons.AutoMirrored.Rounded.ArrowBack
) { ) {
app.openAppDetails(context) onBack()
})
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_launch),
icon = Icons.AutoMirrored.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
if (!app.isPrivate) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Tune,
action = { sheetManager.showCustomizeSearchableModal(app) }
))
}
if (!app.isPrivate) {
val storeDetails = remember(app) { app.getStoreDetails(context) }
val shareAction = if (storeDetails == null) {
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share
) {
scope.launch {
app.shareApkFile(context)
}
}
} else {
SubmenuToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
children = listOf(
DefaultToolbarAction(
label = stringResource(
R.string.menu_share_store_link,
storeDetails.label
),
icon = Icons.Rounded.Link,
action = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, storeDetails.url)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
} }
), ),
DefaultToolbarAction( rightActions = toolbarActions
label = stringResource(R.string.menu_share_apk_file),
icon = Icons.Rounded.Android
) {
scope.launch {
app.shareApkFile(context)
}
}
) )
) }
} else {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalGridSettings.current.showListIcons) {
ShapedLauncherIcon(
size = LocalGridSettings.current.iconSize.dp,
modifier = Modifier
.padding(end = 16.dp),
badge = { badge },
icon = { icon },
)
}
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = app.labelOverride ?: app.label,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent,
),
)
}
} }
toolbarActions.add(shareAction)
} }
if (app.canUninstall) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_uninstall),
icon = Icons.Rounded.Delete,
) {
app.uninstall(context)
onBack()
}
)
}
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.AutoMirrored.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
} }
} }
@ -507,6 +565,7 @@ fun AppItemGridPopup(
y = lerp(-16.dp, 0.dp, animationProgress) y = lerp(-16.dp, 0.dp, animationProgress)
), ),
app = app, app = app,
showDetails = true,
onBack = onDismiss onBack = onDismiss
) )
} }

View File

@ -4,7 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -22,9 +22,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LeadingIconTab import androidx.compose.material3.LeadingIconTab
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -36,6 +36,8 @@ import de.mm20.launcher2.search.Application
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem
import de.mm20.launcher2.ui.launcher.search.common.grid.GridResults import de.mm20.launcher2.ui.launcher.search.common.grid.GridResults
import de.mm20.launcher2.ui.launcher.search.common.list.ListItem
import de.mm20.launcher2.ui.launcher.search.common.list.ListResults
import de.mm20.launcher2.ui.layout.BottomReversed import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.locals.LocalGridSettings
@ -47,158 +49,192 @@ fun LazyListScope.AppResults(
isProfileLocked: Boolean = false, isProfileLocked: Boolean = false,
onProfileLockChange: ((Profile, Boolean) -> Unit)? = null, onProfileLockChange: ((Profile, Boolean) -> Unit)? = null,
apps: List<Application>, apps: List<Application>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
highlightedItem: Application? = null, highlightedItem: Application? = null,
columns: Int, columns: Int,
reverse: Boolean, reverse: Boolean,
showList: Boolean,
) { ) {
val before = if (profiles.size > 1) {
GridResults( @Composable {
key = "apps", Column(
items = apps, verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top,
before = if (profiles.size > 1) { ) {
{ PrimaryScrollableTabRow(
Column( selectedTabIndex = selectedProfileIndex,
verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top, containerColor = Color.Transparent,
edgePadding = 16.dp,
divider = {}
) { ) {
PrimaryScrollableTabRow( for ((i, profile) in profiles.withIndex()) {
selectedTabIndex = selectedProfileIndex, LeadingIconTab(
containerColor = Color.Transparent, selected = selectedProfileIndex == profiles.indexOf(profile),
edgePadding = 16.dp, text = {
divider = {} Text(
) {
for ((i, profile) in profiles.withIndex()) {
LeadingIconTab(
selected = selectedProfileIndex == profiles.indexOf(profile),
text = {
Text(
when (profile.type) {
Profile.Type.Personal -> stringResource(R.string.apps_profile_main)
Profile.Type.Work -> stringResource(R.string.apps_profile_work)
Profile.Type.Private -> stringResource(R.string.apps_profile_private)
}
)
},
icon = {
when (profile.type) { when (profile.type) {
Profile.Type.Personal -> Icon( Profile.Type.Personal -> stringResource(R.string.apps_profile_main)
Icons.Rounded.Person, Profile.Type.Work -> stringResource(R.string.apps_profile_work)
contentDescription = null Profile.Type.Private -> stringResource(R.string.apps_profile_private)
)
Profile.Type.Work -> Icon(
Icons.Rounded.Work,
contentDescription = null
)
Profile.Type.Private -> Icon(
Icons.Rounded.PrivateSpace,
contentDescription = null
)
} }
}, )
onClick = { },
onProfileSelected(i) icon = {
when (profile.type) {
Profile.Type.Personal -> Icon(
Icons.Rounded.Person,
contentDescription = null
)
Profile.Type.Work -> Icon(
Icons.Rounded.Work,
contentDescription = null
)
Profile.Type.Private -> Icon(
Icons.Rounded.PrivateSpace,
contentDescription = null
)
} }
) },
} onClick = {
onProfileSelected(i)
}
)
} }
HorizontalDivider() }
val profileType = profiles[selectedProfileIndex].type if (!showList || isProfileLocked) {
if (profileType != Profile.Type.Personal) { HorizontalDivider()
if (isProfileLocked) { }
Column(
modifier = Modifier val profileType = profiles[selectedProfileIndex].type
.padding(12.dp) if (profileType != Profile.Type.Personal) {
.fillMaxWidth() if (isProfileLocked) {
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.small) Column(
.background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.small) modifier = Modifier
.padding(vertical = 64.dp), .padding(12.dp)
verticalArrangement = Arrangement.Center, .fillMaxWidth()
horizontalAlignment = Alignment.CenterHorizontally, .border(
) { 1.dp,
Icon( MaterialTheme.colorScheme.outlineVariant,
if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock, MaterialTheme.shapes.small
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.secondary,
) )
Text( .background(
stringResource( MaterialTheme.colorScheme.surfaceContainer,
if (profileType == Profile.Type.Work) R.string.profile_work_profile_state_locked MaterialTheme.shapes.small
else R.string.profile_private_profile_state_locked
),
modifier = Modifier.padding(top = 8.dp),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall,
) )
if (showProfileLockControls) { .padding(vertical = 64.dp),
Button( verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 32.dp), horizontalAlignment = Alignment.CenterHorizontally,
onClick = { ) {
onProfileLockChange?.invoke( Icon(
profiles[selectedProfileIndex], if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock,
false contentDescription = null,
) modifier = Modifier.size(48.dp),
}, tint = MaterialTheme.colorScheme.secondary,
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, )
) { Text(
Icon( stringResource(
if (profileType == Profile.Type.Work) Icons.Rounded.Work else Icons.Rounded.LockOpen, if (profileType == Profile.Type.Work) R.string.profile_work_profile_state_locked
contentDescription = null, else R.string.profile_private_profile_state_locked
modifier = Modifier ),
.padding(end = ButtonDefaults.IconSpacing) modifier = Modifier.padding(top = 8.dp),
.size(ButtonDefaults.IconSize) color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall,
)
if (showProfileLockControls) {
Button(
modifier = Modifier.padding(top = 32.dp),
onClick = {
onProfileLockChange?.invoke(
profiles[selectedProfileIndex],
false
) )
Text( },
stringResource( contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_unlock ) {
else R.string.profile_private_profile_action_unlock Icon(
) if (profileType == Profile.Type.Work) Icons.Rounded.Work else Icons.Rounded.LockOpen,
contentDescription = null,
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize)
)
Text(
stringResource(
if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_unlock
else R.string.profile_private_profile_action_unlock
) )
} )
} }
} }
} else if (showProfileLockControls) { }
FilledTonalButton( } else if (showProfileLockControls) {
FilledTonalButton(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth(),
onClick = {
onProfileLockChange?.invoke(
profiles[selectedProfileIndex],
true
)
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
Icon(
if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock,
contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(end = ButtonDefaults.IconSpacing)
.fillMaxWidth(), .size(ButtonDefaults.IconSize)
onClick = { )
onProfileLockChange?.invoke( Text(
profiles[selectedProfileIndex], stringResource(
true if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_lock
) else R.string.profile_private_profile_action_lock
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
Icon(
if (profileType == Profile.Type.Work) Icons.Rounded.WorkOff else Icons.Rounded.Lock,
contentDescription = null,
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize)
) )
Text( )
stringResource(
if (profileType == Profile.Type.Work) R.string.profile_work_profile_action_lock
else R.string.profile_private_profile_action_lock
)
)
}
} }
} }
} }
} }
} else null, }
itemContent = { } else null
GridItem( if (showList) {
item = it, ListResults(
showLabels = LocalGridSettings.current.showLabels, key = "apps",
highlight = it.key == highlightedItem?.key items = apps,
) before = before?.let { { it() } },
}, selectedIndex = selectedIndex,
reverse = reverse, itemContent = { app, showDetails, index ->
columns = columns, ListItem(
) modifier = Modifier
.fillMaxWidth(),
item = app,
showDetails = showDetails,
onShowDetails = { onSelect(if(it) index else -1) },
highlight = highlightedItem?.key == app.key
)
},
reverse = reverse,
)
} else {
GridResults(
key = "apps",
items = apps,
before = before,
itemContent = {
GridItem(
item = it,
showLabels = LocalGridSettings.current.showLabels,
highlight = it.key == highlightedItem?.key
)
},
reverse = reverse,
columns = columns,
)
}
} }

View File

@ -1,6 +1,5 @@
package de.mm20.launcher2.ui.launcher.search.common.grid package de.mm20.launcher2.ui.launcher.search.common.grid
import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
@ -13,6 +12,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -110,6 +110,7 @@ fun GridItem(
Column( Column(
modifier = modifier modifier = modifier
.padding(4.dp)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
if (!launchOnPress || !viewModel.launch(context, bounds)) { if (!launchOnPress || !viewModel.launch(context, bounds)) {
@ -170,7 +171,9 @@ fun GridItem(
modifier = Modifier modifier = Modifier
.padding(4.dp) .padding(4.dp)
.onGloballyPositioned { .onGloballyPositioned {
bounds = it.boundsInWindow().roundToIntRect() bounds = it
.boundsInWindow()
.roundToIntRect()
} then } then
if (highlight) Modifier.background( if (highlight) Modifier.background(
MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.surface,
@ -195,10 +198,10 @@ fun GridItem(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
} }
}
if (showPopup) { if (showPopup) {
ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false }) ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false })
}
} }
} }
@ -398,7 +401,7 @@ private fun Modifier.placeOverlay(
constraints.maxHeight - placeable.height, constraints.maxHeight - placeable.height,
), ),
animationProgress.pow(2) animationProgress.pow(2)
).toInt() )
) )
} }
} }

View File

@ -83,8 +83,8 @@ fun <T : SavableSearchable> LazyListScope.GridResults(
.padding( .padding(
top = if (it == 0) 8.dp else 0.dp, top = if (it == 0) 8.dp else 0.dp,
bottom = if (it == rows - 1) 8.dp else 0.dp, bottom = if (it == rows - 1) 8.dp else 0.dp,
start = 4.dp, start = if (columns == 1) 0.dp else 4.dp,
end = 4.dp, end = if (columns == 1) 0.dp else 4.dp,
) )
) { ) {
Row { Row {
@ -94,7 +94,6 @@ fun <T : SavableSearchable> LazyListScope.GridResults(
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(4.dp)
) { ) {
itemContent(item) itemContent(item)
} }

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -22,6 +23,7 @@ import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.roundToIntRect
import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Article import de.mm20.launcher2.search.Article
import de.mm20.launcher2.search.CalendarEvent import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
@ -30,6 +32,7 @@ import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.Website import de.mm20.launcher2.search.Website
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.apps.AppItem
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem
@ -80,6 +83,25 @@ fun ListItem(
LocalContentColor provides MaterialTheme.colorScheme.onSurface LocalContentColor provides MaterialTheme.colorScheme.onSurface
) { ) {
when (item) { when (item) {
is Application -> {
AppItem(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 9999.dp) // we have infinite space, but there is an inner scroll that needs a constraint
.combinedClickable(
enabled = !showDetails,
onClick = {
if (!viewModel.launch(context, bounds)) {
onShowDetails(true)
}
},
onLongClick = { onShowDetails(true) }
),
app = item,
showDetails = showDetails,
onBack = { onShowDetails(false) }
)
}
is Contact -> { is Contact -> {
ContactItem( ContactItem(
modifier = Modifier modifier = Modifier

View File

@ -110,6 +110,26 @@ fun IconsSettingsScreen() {
viewModel.setShowLabels(it) viewModel.setShowLabels(it)
} }
) )
SwitchPreference(
title = stringResource(R.string.preference_grid_list_style),
summary = stringResource(R.string.preference_grid_list_style_summary),
value = grid.showList,
onValueChanged = {
viewModel.setShowList(it)
}
)
AnimatedVisibility(
grid.showList
) {
SwitchPreference(
title = stringResource(R.string.preference_grid_list_icons),
summary = stringResource(R.string.preference_grid_list_icons_summary),
value = grid.showListIcons,
onValueChanged = {
viewModel.setShowListIcons(it)
}
)
}
SliderPreference( SliderPreference(
title = stringResource(R.string.preference_grid_column_count), title = stringResource(R.string.preference_grid_column_count),
value = grid.columnCount, value = grid.columnCount,

View File

@ -53,6 +53,14 @@ class IconsSettingsScreenVM(
uiSettings.setGridShowLabels(showLabels) uiSettings.setGridShowLabels(showLabels)
} }
fun setShowList(showList: Boolean) {
uiSettings.setGridShowList(showList)
}
fun setShowListIcons(showIcons: Boolean) {
uiSettings.setGridShowListIcons(showIcons)
}
val iconShape = uiSettings.iconShape val iconShape = uiSettings.iconShape
fun setIconShape(iconShape: IconShape) { fun setIconShape(iconShape: IconShape) {
uiSettings.setIconShape(iconShape) uiSettings.setIconShape(iconShape)

View File

@ -557,8 +557,12 @@
<string name="preference_category_grid">Grid</string> <string name="preference_category_grid">Grid</string>
<string name="preference_grid_icon_size">Icon size</string> <string name="preference_grid_icon_size">Icon size</string>
<string name="preference_grid_column_count">Number of columns</string> <string name="preference_grid_column_count">Number of columns</string>
<string name="preference_grid_list_style">Show app results in a list</string>
<string name="preference_grid_list_icons">Show app icons in list</string>
<string name="preference_grid_labels">Show labels</string> <string name="preference_grid_labels">Show labels</string>
<string name="preference_grid_labels_summary">Show the app name below the icon</string> <string name="preference_grid_labels_summary">Show the app name below the icon</string>
<string name="preference_grid_list_style_summary">Show applications in a list view instead of grid</string>
<string name="preference_grid_list_icons_summary">Show icons in the list view</string>
<string name="preference_screen_debug">Debug</string> <string name="preference_screen_debug">Debug</string>
<string name="preference_screen_debug_summary">Troubleshooting tools</string> <string name="preference_screen_debug_summary">Troubleshooting tools</string>
<string name="preference_category_widgets">Widgets</string> <string name="preference_category_widgets">Widgets</string>

View File

@ -1,14 +1,13 @@
package de.mm20.launcher2.preferences package de.mm20.launcher2.preferences
import android.content.Context import android.content.Context
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.SearchFilters import de.mm20.launcher2.search.SearchFilters
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class LauncherSettingsData internal constructor( data class LauncherSettingsData internal constructor(
val schemaVersion: Int = 2, val schemaVersion: Int = 3,
val uiColorScheme: ColorScheme = ColorScheme.System, val uiColorScheme: ColorScheme = ColorScheme.System,
val uiTheme: ThemeDescriptor = ThemeDescriptor.Default, val uiTheme: ThemeDescriptor = ThemeDescriptor.Default,
@ -83,6 +82,8 @@ data class LauncherSettingsData internal constructor(
val gridColumnCount: Int = 5, val gridColumnCount: Int = 5,
val gridIconSize: Int = 48, val gridIconSize: Int = 48,
val gridLabels: Boolean = true, val gridLabels: Boolean = true,
val gridList: Boolean = false,
val gridListIcons: Boolean = true,
val searchBarStyle: SearchBarStyle = SearchBarStyle.Transparent, val searchBarStyle: SearchBarStyle = SearchBarStyle.Transparent,
val searchBarColors: SearchBarColors = SearchBarColors.Auto, val searchBarColors: SearchBarColors = SearchBarColors.Auto,

View File

@ -2,6 +2,7 @@ package de.mm20.launcher2.preferences
import androidx.datastore.core.CorruptionException import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
@ -21,6 +22,7 @@ internal object LauncherSettingsDataSerializer : Serializer<LauncherSettingsData
override val defaultValue: LauncherSettingsData override val defaultValue: LauncherSettingsData
get() = LauncherSettingsData(schemaVersion = 0) get() = LauncherSettingsData(schemaVersion = 0)
@OptIn(ExperimentalSerializationApi::class)
override suspend fun readFrom(input: InputStream): LauncherSettingsData { override suspend fun readFrom(input: InputStream): LauncherSettingsData {
try { try {
return json.decodeFromStream(input) return json.decodeFromStream(input)

View File

@ -25,6 +25,8 @@ data class GridSettings(
val columnCount: Int = 5, val columnCount: Int = 5,
val iconSize: Int = 48, val iconSize: Int = 48,
val showLabels: Boolean = true, val showLabels: Boolean = true,
val showList: Boolean = false,
val showListIcons: Boolean = true,
) )
class UiSettings internal constructor( class UiSettings internal constructor(
@ -48,6 +50,8 @@ class UiSettings internal constructor(
get() = launcherDataStore.data.map { get() = launcherDataStore.data.map {
GridSettings( GridSettings(
showLabels = it.gridLabels, showLabels = it.gridLabels,
showList = it.gridList,
showListIcons = it.gridListIcons,
iconSize = it.gridIconSize, iconSize = it.gridIconSize,
columnCount = it.gridColumnCount, columnCount = it.gridColumnCount,
) )
@ -71,6 +75,17 @@ class UiSettings internal constructor(
} }
} }
fun setGridShowList(showList: Boolean) {
launcherDataStore.update {
it.copy(gridList = showList)
}
}
fun setGridShowListIcons(showIcons: Boolean) {
launcherDataStore.update {
it.copy(gridListIcons = showIcons)
}
}
val cardStyle val cardStyle
get() = launcherDataStore.data.map { get() = launcherDataStore.data.map {