Create shortcuts from edit favorite panel

Close #154
This commit is contained in:
MM20 2022-09-18 20:30:57 +02:00
parent 7a73b72314
commit b068e4d6fd
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
5 changed files with 351 additions and 116 deletions

View File

@ -43,6 +43,7 @@ dependencies {
implementation(libs.commons.text)
implementation(project(":applications"))
implementation(project(":search"))
implementation(project(":permissions"))
implementation(project(":base"))

View File

@ -16,6 +16,7 @@ import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -31,6 +32,8 @@ interface AppShortcutRepository {
count: Int = 5
): List<AppShortcut>
suspend fun getShortcutsConfigActivities(): List<LauncherApp>
fun search(query: String): Flow<List<AppShortcut>>
fun removePinnedShortcut(shortcut: AppShortcut)
@ -204,6 +207,24 @@ internal class AppShortcutRepositoryImpl(
)
}
override suspend fun getShortcutsConfigActivities(): List<LauncherApp> {
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
if (!launcherApps.hasShortcutHostPermission()) return emptyList()
val results = mutableListOf<LauncherApp>()
val profiles = launcherApps.profiles
for (profile in profiles) {
val activities = launcherApps.getShortcutConfigActivityList(null, profile)
results.addAll(
activities.map {
LauncherApp(
context, it
)
}
)
}
return results.sorted()
}
private fun matches(label: String, query: String): Boolean {
val labelLatin = label.normalize()

View File

@ -389,6 +389,8 @@
<string name="missing_permission_contact_search">Grant contact permission to search your contact.</string>
<!-- Missing permission app shortcuts permission, used in app shortcut search results. %1$s: app name -->
<string name="missing_permission_appshortcuts_search">Set %1$s as default home app to search app shortcuts.</string>
<!-- Missing permission app shortcuts permission, used when creating a shortcut in the edit favorites sheet -->
<string name="missing_permission_appshortcuts_create">Set %1$s as default home app to create shortcuts.</string>
<!-- Grant a permission, shown in permission banners -->
<string name="grant_permission">Grant</string>
<!-- Appearance preference title -->
@ -648,4 +650,5 @@
<string name="apps_profile_work">Work</string>
<string name="favorites">Favorites</string>
<string name="create_app_shortcut">Create shortcut</string>
</resources>

View File

@ -1,13 +1,20 @@
package de.mm20.launcher2.ui.launcher.modals
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import android.app.Activity
import android.content.Context
import android.content.pm.LauncherApps
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
@ -16,6 +23,8 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -27,6 +36,7 @@ import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.helper.DraggableItem
@ -45,9 +55,61 @@ fun EditFavoritesSheet(
viewModel.reload()
}
val items by viewModel.gridItems.observeAsState(emptyList())
val loading by viewModel.loading.observeAsState(true)
val createShortcutTarget by viewModel.createShortcutTarget.observeAsState(null)
BottomSheetDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (createShortcutTarget == null) {
stringResource(id = R.string.menu_item_edit_favs)
} else {
stringResource(id = R.string.create_app_shortcut)
}
)
},
swipeToDismiss = {
createShortcutTarget == null
},
dismissOnBackPress = {
createShortcutTarget == null
},
confirmButton = {
if (createShortcutTarget != null) {
OutlinedButton(onClick = { viewModel.cancelPickShortcut() }) {
Text(stringResource(id = android.R.string.cancel))
}
} else {
OutlinedButton(onClick = onDismiss) {
Text(stringResource(id = R.string.close))
}
}
}
) {
if (loading) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
) {
CircularProgressIndicator(
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
)
}
} else if (createShortcutTarget != null) {
ShortcutPicker(viewModel)
} else {
ReorderFavoritesGrid(viewModel)
}
}
}
@Composable
fun ReorderFavoritesGrid(viewModel: EditFavoritesSheetVM) {
val items by viewModel.gridItems.observeAsState(emptyList())
val columns = LocalGridColumns.current
val state = rememberLazyDragAndDropGridState(
@ -60,125 +122,144 @@ fun EditFavoritesSheet(
val iconSize = 48.dp.toPixels()
BottomSheetDialog(onDismissRequest = onDismiss, title = {
Text(stringResource(id = R.string.menu_item_edit_favs))
}) {
if (loading) {
Box(modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)) {
CircularProgressIndicator(
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
)
}
} else {
LazyVerticalDragAndDropGrid(
state = state,
columns = GridCells.Fixed(columns),
LazyVerticalDragAndDropGrid(
state = state,
columns = GridCells.Fixed(columns),
) {
items(
items.size,
key = { i ->
val it = items[i]
if (it is FavoritesSheetGridItem.Favorite) it.item.key else i
},
span = { i ->
val it = items[i]
when (it) {
is FavoritesSheetGridItem.Favorite -> GridItemSpan(1)
is FavoritesSheetGridItem.Divider -> GridItemSpan(columns)
is FavoritesSheetGridItem.EmptySection -> GridItemSpan(columns)
is FavoritesSheetGridItem.Spacer -> GridItemSpan(it.span)
is FavoritesSheetGridItem.Tags -> GridItemSpan(columns)
) {
items(
items.size,
key = { i ->
val it = items[i]
if (it is FavoritesSheetGridItem.Favorite) it.item.key else i
},
span = { i ->
val it = items[i]
when (it) {
is FavoritesSheetGridItem.Favorite -> GridItemSpan(1)
is FavoritesSheetGridItem.Divider -> GridItemSpan(columns)
is FavoritesSheetGridItem.EmptySection -> GridItemSpan(columns)
is FavoritesSheetGridItem.Spacer -> GridItemSpan(it.span)
is FavoritesSheetGridItem.Tags -> GridItemSpan(columns)
}
}
) { i ->
when (val it = items[i]) {
is FavoritesSheetGridItem.Favorite -> {
val icon by remember(it.item.key) {
viewModel.getIcon(
it.item,
iconSize.roundToInt()
)
}.collectAsState(null)
val badge by remember(it.item.key) {
viewModel.getBadge(
it.item,
)
}.collectAsState(null)
DraggableItem(state = state, key = it.item.key) { dragged ->
GridItem(
label = it.item.labelOverride ?: it.item.label,
icon = icon,
badge = badge
)
}
}
) { i ->
when (val it = items[i]) {
is FavoritesSheetGridItem.Favorite -> {
val icon by remember(it.item.key) {
viewModel.getIcon(
it.item,
iconSize.roundToInt()
)
}.collectAsState(null)
val badge by remember(it.item.key) {
viewModel.getBadge(
it.item,
)
}.collectAsState(null)
DraggableItem(state = state, key = it.item.key) { dragged ->
GridItem(
label = it.item.labelOverride ?: it.item.label,
icon = icon,
badge = badge
)
}
is FavoritesSheetGridItem.Divider -> {
val title = when (it.section) {
FavoritesSheetSection.ManuallySorted -> R.string.edit_favorites_dialog_pinned_sorted
FavoritesSheetSection.AutomaticallySorted -> R.string.edit_favorites_dialog_pinned_unsorted
FavoritesSheetSection.FrequentlyUsed -> R.string.edit_favorites_dialog_unpinned
}
is FavoritesSheetGridItem.Divider -> {
Row(
modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
text = stringResource(id = it.titleRes),
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = stringResource(id = title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary
)
if (it.section == FavoritesSheetSection.FrequentlyUsed) {
/*FilledTonalIconToggleButton(
modifier = Modifier.offset(x = 4.dp),
checked = false,
onCheckedChange = {}) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
)
}*/
} else {
FilledTonalIconButton(
modifier = Modifier.offset(x = 4.dp),
onClick = {
viewModel.pickShortcut(it.section)
}) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null
)
}
}
}
is FavoritesSheetGridItem.EmptySection -> {
val shape = MaterialTheme.shapes.medium
val color = MaterialTheme.colorScheme.outline
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.drawBehind {
drawOutline(
outline = shape.createOutline(
size,
layoutDirection,
Density(density, fontScale)
),
color = color,
style = Stroke(
2.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(
4.dp.toPx(),
4.dp.toPx(),
)
}
is FavoritesSheetGridItem.EmptySection -> {
val shape = MaterialTheme.shapes.medium
val color = MaterialTheme.colorScheme.outline
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.drawBehind {
drawOutline(
outline = shape.createOutline(
size,
layoutDirection,
Density(density, fontScale)
),
color = color,
style = Stroke(
2.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(
4.dp.toPx(),
4.dp.toPx(),
)
)
)
}
) {
Text(
modifier = Modifier
.align(Alignment.Center)
.padding(
horizontal = 16.dp,
vertical = 24.dp,
),
text = stringResource(R.string.edit_favorites_dialog_empty_section),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.outline
)
}
}
is FavoritesSheetGridItem.Spacer -> {
Spacer(
)
}
) {
Text(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.align(Alignment.Center)
.padding(
horizontal = 16.dp,
vertical = 24.dp,
),
text = stringResource(R.string.edit_favorites_dialog_empty_section),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.outline
)
}
is FavoritesSheetGridItem.Tags -> {}
}
is FavoritesSheetGridItem.Spacer -> {
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
)
}
is FavoritesSheetGridItem.Tags -> {}
}
}
}
}
}
@ -209,10 +290,81 @@ fun GridItem(
}
}
@Composable
fun ShortcutPicker(viewModel: EditFavoritesSheetVM) {
val hasShortcutPermission by remember { viewModel.hasShortcutPermission }.collectAsState(null)
val shortcutActivities by remember(hasShortcutPermission) { viewModel.getShortcutActivities() }.collectAsState(
emptyList()
)
val context = LocalContext.current
val activityLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) {
if (it.resultCode != Activity.RESULT_OK) {
viewModel.cancelPickShortcut()
}
viewModel.createShortcut(context, it.data)
}
val iconSize = 48.dp.toPixels().roundToInt()
val activity = LocalLifecycleOwner.current as AppCompatActivity
LazyColumn {
if (hasShortcutPermission == false) {
item {
MissingPermissionBanner(
modifier = Modifier.padding(bottom = 16.dp),
text = stringResource(
R.string.missing_permission_appshortcuts_create,
stringResource(R.string.app_name)
),
onClick = { viewModel.requestShortcutPermission(activity) })
}
}
items(shortcutActivities) {
val icon by remember(it.key) { viewModel.getIcon(it, iconSize) }.collectAsState(null)
val badge by remember(it.key) { viewModel.getBadge(it) }.collectAsState(null)
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
onClick = {
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val sender = launcherApps.getShortcutConfigActivityIntent(it.launcherActivityInfo) ?: return@OutlinedCard
activityLauncher.launch(IntentSenderRequest.Builder(sender).build(), null)
}) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ShapedLauncherIcon(
size = 48.dp,
icon = { icon },
badge = { badge },
)
Text(
text = it.labelOverride ?: it.label,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.titleSmall
)
}
}
}
}
}
sealed interface FavoritesSheetGridItem {
class Favorite(val item: Searchable) : FavoritesSheetGridItem
class Divider(val titleRes: Int) : FavoritesSheetGridItem
class Divider(val section: FavoritesSheetSection) : FavoritesSheetGridItem
class Spacer(val span: Int = 1) : FavoritesSheetGridItem
class EmptySection() : FavoritesSheetGridItem
object EmptySection : FavoritesSheetGridItem
class Tags() : FavoritesSheetGridItem
}
enum class FavoritesSheetSection {
ManuallySorted,
AutomaticallySorted,
FrequentlyUsed
}

View File

@ -1,18 +1,29 @@
package de.mm20.launcher2.ui.launcher.modals
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherApps
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.R
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -20,13 +31,17 @@ import org.koin.core.component.inject
class EditFavoritesSheetVM : ViewModel(), KoinComponent {
private val repository: FavoritesRepository by inject()
private val shortcutRepository: AppShortcutRepository by inject()
private val iconRepository: IconRepository by inject()
private val badgeRepository: BadgeRepository by inject()
private val permissionsManager: PermissionsManager by inject()
val gridItems = MutableLiveData<List<FavoritesSheetGridItem>>(emptyList())
val loading = MutableLiveData(false)
val createShortcutTarget = MutableLiveData<FavoritesSheetSection?>(null)
private var manuallySorted: MutableList<Searchable> = mutableListOf()
private var automaticallySorted: MutableList<Searchable> = mutableListOf()
private var frequentlyUsed: MutableList<Searchable> = mutableListOf()
@ -52,25 +67,25 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
items.add(FavoritesSheetGridItem.Tags())
items.add(FavoritesSheetGridItem.Divider(R.string.edit_favorites_dialog_pinned_sorted))
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.ManuallySorted))
if (manuallySorted.isEmpty()) {
items.add(FavoritesSheetGridItem.EmptySection())
items.add(FavoritesSheetGridItem.EmptySection)
} else {
items.addAll(manuallySorted.map { FavoritesSheetGridItem.Favorite(it) })
items.add(FavoritesSheetGridItem.Spacer())
}
items.add(FavoritesSheetGridItem.Divider(R.string.edit_favorites_dialog_pinned_unsorted))
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.AutomaticallySorted))
if (automaticallySorted.isEmpty()) {
items.add(FavoritesSheetGridItem.EmptySection())
items.add(FavoritesSheetGridItem.EmptySection)
} else {
items.addAll(automaticallySorted.map { FavoritesSheetGridItem.Favorite(it) })
items.add(FavoritesSheetGridItem.Spacer())
}
items.add(FavoritesSheetGridItem.Divider(R.string.edit_favorites_dialog_unpinned))
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.FrequentlyUsed))
if (frequentlyUsed.isEmpty()) {
items.add(FavoritesSheetGridItem.EmptySection())
items.add(FavoritesSheetGridItem.EmptySection)
} else {
items.addAll(frequentlyUsed.map { FavoritesSheetGridItem.Favorite(it) })
items.add(FavoritesSheetGridItem.Spacer())
@ -123,6 +138,11 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
)
}
}
save()
buildItemList()
}
private fun save() {
repository.updateFavorites(
buildList {
addAll(manuallySorted)
@ -131,7 +151,6 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
addAll(automaticallySorted)
},
)
buildItemList()
}
fun getIcon(searchable: Searchable, size: Int): Flow<LauncherIcon?> {
@ -142,4 +161,43 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
return badgeRepository.getBadge(searchable)
}
fun pickShortcut(section: FavoritesSheetSection) {
createShortcutTarget.value = section
}
fun cancelPickShortcut() {
createShortcutTarget.value = null
}
fun getShortcutActivities() = flow {
emit(shortcutRepository.getShortcutsConfigActivities())
}
val hasShortcutPermission = permissionsManager.hasPermission(PermissionGroup.AppShortcuts)
fun requestShortcutPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.AppShortcuts)
}
fun createShortcut(context: Context, data: Intent?) {
data ?: return cancelPickShortcut()
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
val pinRequest = launcherApps.getPinItemRequest(data) ?: return cancelPickShortcut()
val shortcutInfo = pinRequest.shortcutInfo ?: return cancelPickShortcut()
pinRequest.accept()
val shortcut = AppShortcut(
context,
shortcutInfo,
context.packageManager.getApplicationInfo(shortcutInfo.`package`, 0)
.loadLabel(context.packageManager).toString()
)
if (createShortcutTarget.value == FavoritesSheetSection.ManuallySorted) {
manuallySorted.add(shortcut)
} else {
automaticallySorted.add(shortcut)
}
save()
buildItemList()
createShortcutTarget.value = null
}
}