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 kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.androidx.compose.inject
@Composable

View File

@ -64,6 +64,7 @@ fun SearchColumn(
) {
val columns = LocalGridSettings.current.columnCount
val showList = LocalGridSettings.current.showList
val context = LocalContext.current
val viewModel: SearchVM = viewModel()
@ -111,6 +112,7 @@ fun SearchColumn(
val expandedCategory: SearchCategory? by viewModel.expandedCategory
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 selectedFileIndex: Int by remember(files) { mutableIntStateOf(-1) }
var selectedCalendarIndex: Int by remember(events) { mutableIntStateOf(-1) }
@ -193,6 +195,9 @@ fun SearchColumn(
columns = columns,
reverse = reverse,
showProfileLockControls = hasProfilesPermission,
showList = showList,
selectedIndex = selectedAppIndex,
onSelect = { selectedAppIndex = it },
)
} else {
AppResults(
@ -202,7 +207,10 @@ fun SearchColumn(
selectedAppProfileIndex = it
},
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 androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.MutableTransitionState
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.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import de.mm20.launcher2.crashreporter.CrashReporter
@ -85,11 +85,15 @@ import kotlinx.coroutines.launch
fun AppItem(
modifier: Modifier = Modifier,
app: Application,
showDetails: Boolean,
onBack: () -> Unit
) {
val viewModel: SearchableItemVM = listItemViewModel(key = "search-${app.key}")
val iconSize = LocalGridSettings.current.iconSize.dp.toPixels()
val badge by viewModel.badge.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
LaunchedEffect(app) {
viewModel.init(app, iconSize.toInt())
}
@ -97,8 +101,11 @@ fun AppItem(
val context = LocalContext.current
val scope = rememberCoroutineScope()
SharedTransitionLayout(modifier = modifier) {
AnimatedContent(showDetails) { showDetails ->
if (showDetails) {
Column(
modifier = modifier.verticalScroll(rememberScrollState())
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row {
Column(
@ -108,7 +115,12 @@ fun AppItem(
) {
Text(
text = app.labelOverride ?: app.label,
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent,
),
)
if (!app.isPrivate) {
@ -150,8 +162,6 @@ fun AppItem(
}
}
val badge by viewModel.badge.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
@ -183,7 +193,11 @@ fun AppItem(
) {
for ((i, not) in notifications.withIndex()) {
val icon =
remember(not.smallIcon) { not.smallIcon?.loadDrawable(context) }
remember(not.smallIcon) {
not.smallIcon?.loadDrawable(
context
)
}
if (not.title == null && not.text == null) continue
@ -195,7 +209,9 @@ fun AppItem(
modifier = Modifier
.clickable {
try {
not.contentIntent?.sendWithBackgroundPermission(context)
not.contentIntent?.sendWithBackgroundPermission(
context
)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
@ -299,7 +315,11 @@ fun AppItem(
.clip(MaterialTheme.shapes.small)
) {
for ((i, shortcut) in shortcuts.withIndex()) {
val isPinned by remember(shortcut) { viewModel.isChildPinned(shortcut) }.collectAsState(
val isPinned by remember(shortcut) {
viewModel.isChildPinned(
shortcut
)
}.collectAsState(
false
)
@ -404,7 +424,8 @@ fun AppItem(
val sheetManager = LocalBottomSheetManager.current
if (!app.isPrivate) {
toolbarActions.add(DefaultToolbarAction(
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Tune,
action = { sheetManager.showCustomizeSearchableModal(app) }
@ -435,9 +456,17 @@ fun AppItem(
icon = Icons.Rounded.Link,
action = {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, storeDetails.url)
shareIntent.putExtra(
Intent.EXTRA_TEXT,
storeDetails.url
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
context.startActivity(
Intent.createChooser(
shareIntent,
null
)
)
}
),
DefaultToolbarAction(
@ -478,6 +507,35 @@ fun AppItem(
rightActions = toolbarActions
)
}
} 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,
),
)
}
}
}
}
}
@Composable
@ -507,6 +565,7 @@ fun AppItemGridPopup(
y = lerp(-16.dp, 0.dp, animationProgress)
),
app = app,
showDetails = true,
onBack = onDismiss
)
}

View File

@ -4,7 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
@ -22,9 +22,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LeadingIconTab
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.launcher.search.common.grid.GridItem
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.locals.LocalGridSettings
@ -47,16 +49,15 @@ fun LazyListScope.AppResults(
isProfileLocked: Boolean = false,
onProfileLockChange: ((Profile, Boolean) -> Unit)? = null,
apps: List<Application>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
highlightedItem: Application? = null,
columns: Int,
reverse: Boolean,
showList: Boolean,
) {
GridResults(
key = "apps",
items = apps,
before = if (profiles.size > 1) {
{
val before = if (profiles.size > 1) {
@Composable {
Column(
verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top,
) {
@ -102,7 +103,10 @@ fun LazyListScope.AppResults(
)
}
}
if (!showList || isProfileLocked) {
HorizontalDivider()
}
val profileType = profiles[selectedProfileIndex].type
if (profileType != Profile.Type.Personal) {
@ -111,8 +115,15 @@ fun LazyListScope.AppResults(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth()
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.small)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.background(
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.shapes.small
)
.padding(vertical = 64.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@ -190,7 +201,30 @@ fun LazyListScope.AppResults(
}
}
}
} else null,
} else null
if (showList) {
ListResults(
key = "apps",
items = apps,
before = before?.let { { it() } },
selectedIndex = selectedIndex,
itemContent = { app, showDetails, index ->
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,
@ -201,4 +235,6 @@ fun LazyListScope.AppResults(
reverse = reverse,
columns = columns,
)
}
}

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
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.roundToIntRect
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.Article
import de.mm20.launcher2.search.CalendarEvent
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.Website
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.common.SearchableItemVM
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem
@ -80,6 +83,25 @@ fun ListItem(
LocalContentColor provides MaterialTheme.colorScheme.onSurface
) {
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 -> {
ContactItem(
modifier = Modifier

View File

@ -110,6 +110,26 @@ fun IconsSettingsScreen() {
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(
title = stringResource(R.string.preference_grid_column_count),
value = grid.columnCount,

View File

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

View File

@ -557,8 +557,12 @@
<string name="preference_category_grid">Grid</string>
<string name="preference_grid_icon_size">Icon size</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_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_summary">Troubleshooting tools</string>
<string name="preference_category_widgets">Widgets</string>

View File

@ -1,14 +1,13 @@
package de.mm20.launcher2.preferences
import android.content.Context
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.SearchFilters
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LauncherSettingsData internal constructor(
val schemaVersion: Int = 2,
val schemaVersion: Int = 3,
val uiColorScheme: ColorScheme = ColorScheme.System,
val uiTheme: ThemeDescriptor = ThemeDescriptor.Default,
@ -83,6 +82,8 @@ data class LauncherSettingsData internal constructor(
val gridColumnCount: Int = 5,
val gridIconSize: Int = 48,
val gridLabels: Boolean = true,
val gridList: Boolean = false,
val gridListIcons: Boolean = true,
val searchBarStyle: SearchBarStyle = SearchBarStyle.Transparent,
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.Serializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@ -21,6 +22,7 @@ internal object LauncherSettingsDataSerializer : Serializer<LauncherSettingsData
override val defaultValue: LauncherSettingsData
get() = LauncherSettingsData(schemaVersion = 0)
@OptIn(ExperimentalSerializationApi::class)
override suspend fun readFrom(input: InputStream): LauncherSettingsData {
try {
return json.decodeFromStream(input)

View File

@ -25,6 +25,8 @@ data class GridSettings(
val columnCount: Int = 5,
val iconSize: Int = 48,
val showLabels: Boolean = true,
val showList: Boolean = false,
val showListIcons: Boolean = true,
)
class UiSettings internal constructor(
@ -48,6 +50,8 @@ class UiSettings internal constructor(
get() = launcherDataStore.data.map {
GridSettings(
showLabels = it.gridLabels,
showList = it.gridList,
showListIcons = it.gridListIcons,
iconSize = it.gridIconSize,
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
get() = launcherDataStore.data.map {