diff --git a/appshortcuts/build.gradle.kts b/appshortcuts/build.gradle.kts index 4fd77504..67acd0c6 100644 --- a/appshortcuts/build.gradle.kts +++ b/appshortcuts/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(libs.commons.text) + implementation(project(":applications")) implementation(project(":search")) implementation(project(":permissions")) implementation(project(":base")) diff --git a/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt b/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt index f56ea4ba..127650ca 100644 --- a/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt +++ b/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt @@ -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 + suspend fun getShortcutsConfigActivities(): List + fun search(query: String): Flow> fun removePinnedShortcut(shortcut: AppShortcut) @@ -204,6 +207,24 @@ internal class AppShortcutRepositoryImpl( ) } + override suspend fun getShortcutsConfigActivities(): List { + val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps + if (!launcherApps.hasShortcutHostPermission()) return emptyList() + val results = mutableListOf() + 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() diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 9310e23e..7bd51c2a 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -389,6 +389,8 @@ Grant contact permission to search your contact. Set %1$s as default home app to search app shortcuts. + + Set %1$s as default home app to create shortcuts. Grant @@ -648,4 +650,5 @@ Work Favorites + Create shortcut \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt index d581b6f1..36de44fc 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt @@ -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 } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt index 7e3b30b5..ee9c8f54 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt @@ -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>(emptyList()) val loading = MutableLiveData(false) + val createShortcutTarget = MutableLiveData(null) + private var manuallySorted: MutableList = mutableListOf() private var automaticallySorted: MutableList = mutableListOf() private var frequentlyUsed: MutableList = 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 { @@ -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 + } + } \ No newline at end of file