diff --git a/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_18_19.kt b/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_18_19.kt index 382deff7..b10f3c4f 100644 --- a/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_18_19.kt +++ b/database/src/main/java/de/mm20/launcher2/database/migrations/Migration_18_19.kt @@ -25,7 +25,7 @@ class Migration_18_19 : Migration(18, 19) { while (websearches.moveToNext()) { val label = websearches.getString(0) val data = websearches.getString(1) - val color = websearches.getInt(2) + val color = 0 val icon = websearches.getStringOrNull(3) val encoding = websearches.getStringOrNull(4) diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 33943460..267a194b 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -238,6 +238,8 @@ Close Save + Skip + Continue Turn off Adjust height @@ -651,7 +653,7 @@ Favorites & hidden apps Settings Web search shortcuts - Web & app search shortcuts + Quick actions Built-in widgets Customizations The backup has been completed. @@ -688,4 +690,19 @@ Add to contacts View website Schedule event + + What kind of action do you want to create? + Search on a website + Search in an app + Custom intent + New quick action + Edit quick action + Pick an app to search: + Enter the address of the website: + The given website cannot automatically be imported as a web search. You can try a different website or enter the required data manually in the next step. + Name + App + URL template + The URL template that is used to construct the web search URL. Use ‘${1}’ as a placeholder for the actual search term, e.g. https://google.com?q=${1}. + More information \ No newline at end of file diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt index a4025da8..497d2cab 100644 --- a/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt @@ -1,8 +1,11 @@ package de.mm20.launcher2.searchactions +import android.app.SearchManager +import android.app.SearchableInfo +import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.ResolveInfo +import android.content.pm.PackageManager import android.graphics.Bitmap import android.net.Uri import android.util.Log @@ -14,7 +17,6 @@ import coil.size.Scale import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.searchactions.actions.SearchAction import de.mm20.launcher2.searchactions.actions.SearchActionIcon -import de.mm20.launcher2.searchactions.builders.CallActionBuilder import de.mm20.launcher2.searchactions.builders.SearchActionBuilder import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder import kotlinx.collections.immutable.ImmutableList @@ -47,7 +49,7 @@ interface SearchActionService { suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder? - suspend fun getSearchActivities(): List + suspend fun getSearchActivities(): List suspend fun createIcon(uri: Uri, size: Int): String? } @@ -100,6 +102,16 @@ internal class SearchActionServiceImpl( } else { "https://$url" } + + if (u.contains("${1}")) { + return@withContext WebsearchActionBuilder( + urlTemplate = u, + label = "", + iconColor = 0, + icon = SearchActionIcon.Search, + ) + } + val document = Jsoup.parse(URL(u), 5000) val metaElements = document.select("link[rel=\"search\"][href][type=\"application/opensearchdescription+xml\"]") @@ -185,6 +197,7 @@ internal class SearchActionServiceImpl( label = label ?: "", icon = if (localIconUrl == null) SearchActionIcon.Search else SearchActionIcon.Custom, customIcon = localIconUrl, + iconColor = if (localIconUrl == null) 0 else 1, urlTemplate = urlTemplate ?: "" ) } @@ -214,9 +227,19 @@ internal class SearchActionServiceImpl( return@withContext file.absolutePath } - override suspend fun getSearchActivities(): List { - val packageManager = context.packageManager - val intent = Intent(Intent.ACTION_SEARCH) - return packageManager.queryIntentActivities(intent, 0) + override suspend fun getSearchActivities(): List { + return withContext(Dispatchers.Default) { + val resolveInfos = context.packageManager.queryIntentActivities( + Intent(Intent.ACTION_SEARCH).addCategory(Intent.CATEGORY_DEFAULT), PackageManager.GET_META_DATA, + ) + resolveInfos.mapNotNull { + if (!it.activityInfo.exported || !it.activityInfo.enabled) return@mapNotNull null + if (it.activityInfo.permission != null && context.checkSelfPermission(it.activityInfo.permission) != PackageManager.PERMISSION_GRANTED) { + return@mapNotNull null + } + val componentName = ComponentName(it.activityInfo.packageName, it.activityInfo.name) + componentName + } + } } } \ No newline at end of file diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/AppSearchAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/AppSearchAction.kt index 1e7bb252..701deeb1 100644 --- a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/AppSearchAction.kt +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/AppSearchAction.kt @@ -10,15 +10,16 @@ data class AppSearchAction( override val label: String, val componentName: ComponentName, val query: String, + override val icon: SearchActionIcon = SearchActionIcon.Custom, + override val iconColor: Int = 1, + override val customIcon: String? = null, ): SearchAction { - override val icon: SearchActionIcon = SearchActionIcon.Search - override val iconColor: Int = 0 - override val customIcon: String? = null override fun start(context: Context) { val intent = Intent(Intent.ACTION_SEARCH).apply { component = componentName putExtra(SearchManager.QUERY, query) + putExtra(SearchManager.USER_QUERY, query) } context.tryStartActivity(intent) } diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CustomIntentAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CustomIntentAction.kt new file mode 100644 index 00000000..633c4e06 --- /dev/null +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CustomIntentAction.kt @@ -0,0 +1,22 @@ +package de.mm20.launcher2.searchactions.actions + +import android.content.Context +import android.content.Intent +import de.mm20.launcher2.ktx.tryStartActivity + +class CustomIntentAction( + override val label: String, + val query: String, + val queryKey: String, + val baseIntent: Intent, + override val icon: SearchActionIcon = SearchActionIcon.Custom, + override val iconColor: Int = 1, + override val customIcon: String? = null, +) : SearchAction { + override fun start(context: Context) { + val intent = Intent(baseIntent).also { + it.putExtra(queryKey, query) + } + context.tryStartActivity(intent) + } +} \ No newline at end of file diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/AppSearchActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/AppSearchActionBuilder.kt index 4e462c5b..5b53d233 100644 --- a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/AppSearchActionBuilder.kt +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/AppSearchActionBuilder.kt @@ -8,10 +8,10 @@ import de.mm20.launcher2.searchactions.actions.AppSearchAction import de.mm20.launcher2.searchactions.actions.SearchAction import de.mm20.launcher2.searchactions.actions.SearchActionIcon -class AppSearchActionBuilder( +data class AppSearchActionBuilder( override val label: String, val componentName: ComponentName, - override val icon: SearchActionIcon = SearchActionIcon.Search, + override val icon: SearchActionIcon = SearchActionIcon.Custom, override val iconColor: Int = 0, override val customIcon: String? = null, ) : CustomizableSearchActionBuilder { @@ -23,6 +23,9 @@ class AppSearchActionBuilder( label = label, componentName = componentName, query = classifiedQuery.text, + icon = icon, + iconColor = iconColor, + customIcon = customIcon, ) } } \ No newline at end of file diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CustomIntentActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CustomIntentActionBuilder.kt new file mode 100644 index 00000000..d82f1c98 --- /dev/null +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CustomIntentActionBuilder.kt @@ -0,0 +1,26 @@ +package de.mm20.launcher2.searchactions.builders + +import android.content.Context +import android.content.Intent +import de.mm20.launcher2.searchactions.TextClassificationResult +import de.mm20.launcher2.searchactions.actions.CustomIntentAction +import de.mm20.launcher2.searchactions.actions.SearchAction +import de.mm20.launcher2.searchactions.actions.SearchActionIcon + +data class CustomIntentActionBuilder( + override val label: String, + val queryKey: String, + val baseIntent: Intent, + override val icon: SearchActionIcon = SearchActionIcon.Custom, + override val iconColor: Int = 1, + override val customIcon: String? = null, +) : CustomizableSearchActionBuilder { + override val key: String + get() = "intent://${baseIntent.toUri(0)}" + + override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction { + return CustomIntentAction( + label, classifiedQuery.text, queryKey, baseIntent, icon, iconColor, customIcon + ) + } +} \ No newline at end of file diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt index 1b508f5b..4e60e738 100644 --- a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt @@ -1,6 +1,8 @@ package de.mm20.launcher2.searchactions.builders +import android.content.ComponentName import android.content.Context +import android.content.Intent import android.media.metrics.Event import de.mm20.launcher2.database.entities.SearchActionEntity import de.mm20.launcher2.ktx.jsonObjectOf @@ -42,7 +44,23 @@ interface SearchActionBuilder { ) } "app" -> { - return null + return AppSearchActionBuilder( + label = entity.label ?: "", + componentName = ComponentName.unflattenFromString(entity.data ?: return null) ?: return null, + iconColor = entity.color ?: 0, + icon = SearchActionIcon.fromInt(entity.icon), + customIcon = entity.customIcon, + ) + } + "intent" -> { + return CustomIntentActionBuilder( + entity.label ?: "", + baseIntent = Intent.parseUri(entity.data, 0), + iconColor = entity.color ?: 0, + icon = SearchActionIcon.fromInt(entity.icon), + customIcon = entity.customIcon, + queryKey = options?.getString("extra")?.takeIf { it.isNotEmpty() } ?: return null + ) } "call" -> return CallActionBuilder(context) "message" -> return MessageActionBuilder(context) @@ -70,7 +88,28 @@ interface SearchActionBuilder { "encoding" to builder.encoding.toInt() ).toString() ) - //is AppSearchActionBuilder -> null + is AppSearchActionBuilder -> SearchActionEntity( + position = position, + type = "app", + label = builder.label, + data = builder.componentName.flattenToShortString(), + color = builder.iconColor, + icon = builder.icon.toInt(), + customIcon = builder.customIcon, + options = null + ) + is CustomIntentActionBuilder -> SearchActionEntity( + position = position, + type = "intent", + label = builder.label, + data = builder.baseIntent.toUri(0), + color = builder.iconColor, + icon = builder.icon.toInt(), + customIcon = builder.customIcon, + options = jsonObjectOf( + "extra" to builder.queryKey + ).toString() + ) else -> SearchActionEntity( position = position, type = builder.key, diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt index 3dc7a0a0..6856a945 100644 --- a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt +++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt @@ -8,7 +8,7 @@ import de.mm20.launcher2.searchactions.actions.SearchAction import de.mm20.launcher2.searchactions.actions.SearchActionIcon import java.net.URLEncoder -class WebsearchActionBuilder( +data class WebsearchActionBuilder( override val label: String, val urlTemplate: String, override val icon: SearchActionIcon = SearchActionIcon.Search, diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt index 5a908251..fdd0d08f 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/BottomSheetDialog.kt @@ -3,7 +3,19 @@ package de.mm20.launcher2.ui.component import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.FixedThreshold import androidx.compose.material.FractionalThreshold @@ -13,7 +25,12 @@ import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -193,16 +210,15 @@ fun BottomSheetDialog( } } - val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp) - Surface( - modifier = Modifier - .wrapContentHeight() - .fillMaxWidth(), - tonalElevation = elevation, - ) { - - if (confirmButton != null || dismissButton != null) { + if (confirmButton != null || dismissButton != null) { + val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp) + Surface( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + tonalElevation = elevation, + ) { Row( modifier = Modifier .fillMaxWidth() diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt index 241648c6..b96fcedd 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/SearchActionIcon.kt @@ -1,5 +1,8 @@ package de.mm20.launcher2.ui.component +import android.content.ComponentName +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Alarm import androidx.compose.material.icons.rounded.Call @@ -7,6 +10,7 @@ import androidx.compose.material.icons.rounded.Email import androidx.compose.material.icons.rounded.Event import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonAdd import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Sms import androidx.compose.material.icons.rounded.Timer @@ -14,37 +18,103 @@ import androidx.compose.material.icons.rounded.Translate import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import de.mm20.launcher2.searchactions.actions.AppSearchAction +import de.mm20.launcher2.searchactions.actions.SearchAction import de.mm20.launcher2.searchactions.actions.SearchActionIcon +import de.mm20.launcher2.searchactions.builders.AppSearchActionBuilder +import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun SearchActionIcon( icon: SearchActionIcon, - color: Int, - customIcon: String? = null + color: Int = 0, + customIcon: String? = null, + componentName: ComponentName? = null, + size: Dp = 20.dp ) { val tint = when(color) { 0 -> MaterialTheme.colorScheme.primary 1 -> Color.Unspecified else -> Color(color) } - if (icon != SearchActionIcon.Custom) { + if (icon != SearchActionIcon.Custom || customIcon == null && componentName == null) { Icon( + modifier = Modifier.size(size), imageVector = getSearchActionIconVector(icon), contentDescription = null, tint = tint, ) + } else if (customIcon == null && componentName != null) { + val context = LocalContext.current + var drawable by remember(componentName) { mutableStateOf(null) } + + LaunchedEffect(componentName) { + drawable = withContext(Dispatchers.IO) { + context.packageManager.getActivityIcon(componentName) + } + } + + AsyncImage( + model = drawable, + contentDescription = null, + modifier = Modifier.size(size), + colorFilter = if (tint.isSpecified) ColorFilter.tint(tint) else null + ) + } else { + AsyncImage( + model = customIcon, + contentDescription = null, + modifier = Modifier.size(size), + colorFilter = if (tint.isSpecified) ColorFilter.tint(tint) else null + ) } } +@Composable +fun SearchActionIcon(action: SearchAction, size: Dp = 20.dp) { + SearchActionIcon( + icon = action.icon, + color = action.iconColor, + customIcon = action.customIcon, + componentName = (action as? AppSearchAction)?.componentName, + size = size, + ) +} + +@Composable +fun SearchActionIcon(builder: CustomizableSearchActionBuilder, size: Dp = 20.dp) { + SearchActionIcon( + icon = builder.icon, + color = builder.iconColor, + customIcon = builder.customIcon, + componentName = (builder as? AppSearchActionBuilder)?.componentName, + size = size, + ) +} + fun getSearchActionIconVector(icon: SearchActionIcon): ImageVector { return when (icon) { SearchActionIcon.Phone -> Icons.Rounded.Call SearchActionIcon.Website -> Icons.Rounded.Language SearchActionIcon.Alarm -> Icons.Rounded.Alarm SearchActionIcon.Timer -> Icons.Rounded.Timer - SearchActionIcon.Contact -> Icons.Rounded.Person + SearchActionIcon.Contact -> Icons.Rounded.PersonAdd SearchActionIcon.Email -> Icons.Rounded.Email SearchActionIcon.Message -> Icons.Rounded.Sms SearchActionIcon.Calendar -> Icons.Rounded.Event diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt index 69166ae1..27f5b379 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt @@ -47,9 +47,7 @@ fun SearchBarActions( label = { Text(it.label) }, leadingIcon = { SearchActionIcon( - icon = it.icon, - color = it.iconColor, - customIcon = it.customIcon + action = it ) } /*leadingIcon = { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheet.kt new file mode 100644 index 00000000..784aae5a --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheet.kt @@ -0,0 +1,706 @@ +package de.mm20.launcher2.ui.settings.searchactions + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ManageSearch +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.TravelExplore +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.searchactions.actions.SearchActionIcon +import de.mm20.launcher2.searchactions.builders.AppSearchActionBuilder +import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder +import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.BottomSheetDialog +import de.mm20.launcher2.ui.component.SearchActionIcon +import de.mm20.launcher2.ui.ktx.toPixels + +@Composable +fun EditSearchActionSheet( + initialSearchAction: CustomizableSearchActionBuilder?, + onSave: (CustomizableSearchActionBuilder) -> Unit, + onDismiss: () -> Unit, + onDelete: () -> Unit = {}, +) { + val viewModel: EditSearchActionSheetVM = viewModel() + LaunchedEffect(initialSearchAction) { + viewModel.init(initialSearchAction) + } + val createNew by viewModel.createNew + val page by viewModel.currentPage + + val searchAction by viewModel.searchAction + BottomSheetDialog( + onDismissRequest = { + viewModel.onDismiss() + onDismiss() + }, + dismissOnBackPress = { + page != EditSearchActionPage.PickIcon + }, + swipeToDismiss = { + page != EditSearchActionPage.PickIcon + }, + confirmButton = when (page) { + EditSearchActionPage.CustomizeAppSearch, + EditSearchActionPage.CustomizeWebSearch, + EditSearchActionPage.CustomizeCustomIntent -> { + { + Button(onClick = { + if (viewModel.validate()) { + viewModel.onSave() + searchAction?.let { onSave(it) } + } + }) { + Text(stringResource(R.string.save)) + } + } + } + + EditSearchActionPage.InitWebSearch -> { + { + val density = LocalDensity.current + Button( + onClick = { + if (viewModel.skipWebsearchImport.value) { + viewModel.skipWebsearchImport() + } else { + viewModel.importWebsearch(density) + } + }, + enabled = !viewModel.loadingWebsearch.value + ) { + Text( + stringResource( + if (viewModel.skipWebsearchImport.value) { + R.string.skip + } else { + R.string.action_continue + } + ) + ) + } + } + } + + EditSearchActionPage.PickIcon -> { + { + OutlinedButton(onClick = { + viewModel.applyIcon() + }) { + Text(stringResource(R.string.ok)) + } + } + } + + else -> null + }, + actions = { + var showMenu by remember { mutableStateOf(false) } + if (!createNew) { + IconButton(onClick = { showMenu = !showMenu }) { + Icon(imageVector = Icons.Rounded.MoreVert, contentDescription = null) + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.menu_delete)) }, + leadingIcon = { + Icon(imageVector = Icons.Rounded.Delete, contentDescription = null) + }, + onClick = { + onDelete() + showMenu = false + } + ) + } + } + } + }, + title = { + Text( + stringResource( + if (createNew) { + R.string.create_search_action_title + } else { + R.string.edit_search_action_title + } + ) + ) + }) { + when (page) { + EditSearchActionPage.SelectType -> SelectTypePage(viewModel) + EditSearchActionPage.InitWebSearch -> InitWebSearchPage(viewModel) + EditSearchActionPage.InitAppSearch -> InitAppSearchPage(viewModel) + EditSearchActionPage.CustomizeWebSearch -> CustomizeWebSearch(viewModel) + EditSearchActionPage.CustomizeCustomIntent -> CustomizeCustomIntent(viewModel) + EditSearchActionPage.CustomizeAppSearch -> CustomizeAppSearch(viewModel) + EditSearchActionPage.PickIcon -> PickIcon(viewModel) + } + } +} + +@Composable +private fun SelectTypePage(viewModel: EditSearchActionSheetVM) { + Column { + Text( + text = stringResource(R.string.create_search_action_type), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary + ) + OutlinedCard( + onClick = { viewModel.initWebSearch() }, + modifier = Modifier + .padding(top = 12.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.TravelExplore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.create_search_action_type_web), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.labelLarge, + + ) + } + } + OutlinedCard( + onClick = { viewModel.initAppSearch() }, + modifier = Modifier + .padding(top = 12.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.ManageSearch, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.create_search_action_type_app), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + OutlinedCard( + onClick = { viewModel.initCustomIntent() }, + modifier = Modifier + .padding(top = 12.dp, bottom = 16.dp) + .fillMaxWidth() + ) { + + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Android, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.create_search_action_type_intent), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } +} + +@Composable +private fun InitAppSearchPage(viewModel: EditSearchActionSheetVM) { + val context = LocalContext.current + val searchableApps by remember { viewModel.getSearchableApps(context) }.collectAsState(null) + + if (searchableApps != null) { + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp) + ) { + item { + Text( + text = stringResource(R.string.create_search_action_pick_app), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary + ) + } + items(searchableApps!!) { + OutlinedCard( + onClick = { viewModel.selectSearchableApp(it) }, + modifier = Modifier + .padding(top = 12.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SearchActionIcon( + size = 24.dp, + componentName = it.componentName, + icon = de.mm20.launcher2.searchactions.actions.SearchActionIcon.Custom, + color = 1, + ) + Text( + text = it.label, + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + } +} + +@Composable +private fun InitWebSearchPage(viewModel: EditSearchActionSheetVM) { + var url by viewModel.initWebsearchUrl + val importError by viewModel.websearchImportError + val loading by viewModel.loadingWebsearch + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.create_search_action_website_url), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary + ) + val density = LocalDensity.current + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + value = url, onValueChange = { url = it }, + singleLine = true, + keyboardActions = KeyboardActions(onDone = { viewModel.importWebsearch(density) }), + enabled = !loading, + trailingIcon = { + if (loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + ) + if (importError) { + Surface( + modifier = Modifier.padding(top = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.create_search_action_website_invalid_url), + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +fun CustomizeWebSearch(viewModel: EditSearchActionSheetVM) { + val searchAction by viewModel.searchAction + + Column { + + if (searchAction != null && searchAction is WebsearchActionBuilder) { + Row( + modifier = Modifier.padding(bottom = 16.dp), + verticalAlignment = Alignment.Bottom + ) { + SearchActionIconTile(onClick = { + viewModel.openIconPicker() + }) { + SearchActionIcon( + builder = searchAction!!, size = 24.dp + ) + } + OutlinedTextField( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + value = searchAction!!.label, + onValueChange = { viewModel.setLabel(it) }, + label = { Text(stringResource(R.string.search_action_label)) }, + ) + } + + val placeholderBackground = MaterialTheme.colorScheme.tertiary + val placeholderColor = MaterialTheme.colorScheme.onTertiary + OutlinedTextField( + modifier = Modifier + .fillMaxWidth(), + singleLine = true, + value = (searchAction as WebsearchActionBuilder).urlTemplate, + onValueChange = { viewModel.setUrlTemplate(it) }, + label = { Text(stringResource(R.string.search_action_websearch_url)) }, + supportingText = { + if (viewModel.websearchInvalidUrlError.value) { + Text(stringResource(R.string.websearch_dialog_url_error)) + } else { + Column { + Text(stringResource(R.string.search_action_websearch_url_hint)) + /** TODO: Write user guide for this and link it here + Text( + stringResource(R.string.more_information), + modifier = Modifier + .padding(vertical = 4.dp) + .clickable { }, + color = MaterialTheme.colorScheme.secondary, + style = LocalTextStyle.current.copy(textDecoration = TextDecoration.Underline) + ) + */ + } + } + }, + isError = viewModel.websearchInvalidUrlError.value, + visualTransformation = { + TransformedText(buildAnnotatedString { + append(it) + val placeholderIndex = it.indexOf("\${1}") + if (placeholderIndex != -1) { + addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = placeholderColor, + background = placeholderBackground, + ), + placeholderIndex, placeholderIndex + 4 + ) + } + }, OffsetMapping.Identity) + } + ) + } + } +} + +@Composable +fun CustomizeAppSearch(viewModel: EditSearchActionSheetVM) { + val searchAction by viewModel.searchAction + val context = LocalContext.current + + val availableSearchApps by remember { viewModel.getSearchableApps(context) }.collectAsState( + initial = emptyList() + ) + val selectedApp = + remember(availableSearchApps, (searchAction as? AppSearchActionBuilder)?.componentName) { + availableSearchApps.find { it.componentName == (searchAction as? AppSearchActionBuilder)?.componentName } + } + + Column { + + if (searchAction != null) { + + Row( + modifier = Modifier.padding(bottom = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + SearchActionIconTile(onClick = { + viewModel.openIconPicker() + }) { + SearchActionIcon( + builder = searchAction!!, size = 24.dp + ) + } + OutlinedTextField( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + value = searchAction!!.label, + onValueChange = { viewModel.setLabel(it) }, + label = { Text(stringResource(R.string.search_action_label)) }, + ) + } + + var showAppDropdown by remember { mutableStateOf(false) } + Box(modifier = Modifier + .fillMaxWidth() + .clickable { showAppDropdown = !showAppDropdown }) { + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = selectedApp?.label ?: "", + enabled = true, + label = { Text(stringResource(R.string.search_action_app)) }, + innerTextField = { + Text( + selectedApp?.label ?: "", + modifier = Modifier.fillMaxWidth() + ) + }, + interactionSource = remember { MutableInteractionSource() }, + singleLine = true, + visualTransformation = VisualTransformation.None, + leadingIcon = { + if (selectedApp != null) { + SearchActionIcon( + size = 24.dp, + componentName = selectedApp.componentName, + icon = de.mm20.launcher2.searchactions.actions.SearchActionIcon.Custom, + color = 1, + ) + } + }, + trailingIcon = { + Icon(imageVector = Icons.Rounded.ArrowDropDown, contentDescription = null) + } + ) + DropdownMenu( + expanded = showAppDropdown, + onDismissRequest = { showAppDropdown = false }) { + for (app in availableSearchApps) { + DropdownMenuItem( + text = { Text(app.label) }, + onClick = { + viewModel.setComponentName(app.componentName) + showAppDropdown = false + }, + leadingIcon = { + SearchActionIcon( + size = 24.dp, + componentName = app.componentName, + icon = de.mm20.launcher2.searchactions.actions.SearchActionIcon.Custom, + color = 1, + ) + } + ) + } + } + } + } + } +} + +@Composable +fun CustomizeCustomIntent(viewModel: EditSearchActionSheetVM) { + val searchAction by viewModel.searchAction + + if (searchAction != null) { + Column { + Row( + verticalAlignment = Alignment.Bottom + ) { + SearchActionIconTile(onClick = { + viewModel.openIconPicker() + }) { + SearchActionIcon( + builder = searchAction!!, size = 24.dp + ) + } + OutlinedTextField( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + value = searchAction!!.label, + onValueChange = { viewModel.setLabel(it) }, + label = { Text(stringResource(R.string.search_action_label)) }, + ) + } + } + } +} + +@Composable +fun PickIcon(viewModel: EditSearchActionSheetVM) { + val action by viewModel.searchAction + + val iconSizePx = 20.dp.toPixels() + + val pickIconLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + if (it != null) viewModel.importIcon(it, iconSizePx.toInt()) + } + + if (action?.customIcon == null) { + + val availableIcons = + remember { SearchActionIcon.values().filter { it != SearchActionIcon.Custom } } + + Column { + LazyVerticalGrid(columns = GridCells.Adaptive(64.dp)) { + if (action is AppSearchActionBuilder) { + item { + Box( + modifier = Modifier.padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + val isSelected = + action?.icon == SearchActionIcon.Custom && action?.customIcon == null + SearchActionIconTile(isSelected, onClick = { + viewModel.setCustomIcon(null) + }) { + SearchActionIcon( + icon = SearchActionIcon.Custom, + componentName = (action as AppSearchActionBuilder).componentName, + size = 24.dp, + color = 1, + ) + } + } + } + } + items(availableIcons) { + Box( + modifier = Modifier.padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + val isSelected = action?.icon == it + SearchActionIconTile(isSelected, onClick = { + viewModel.setIcon(it) + }) { + SearchActionIcon( + icon = it, + size = 24.dp, + color = 0, + ) + } + } + } + } + TextButton( + modifier = Modifier.padding(top = 16.dp, bottom = 24.dp), + onClick = { pickIconLauncher.launch("image/*") }) { + Text(stringResource(R.string.websearch_dialog_custom_icon)) + } + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + SearchActionIconTile { + SearchActionIcon(builder = action!!, size = 24.dp) + } + Row( + modifier = Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.padding(end = 16.dp), + text = "Monochrome", + textAlign = TextAlign.End, + style = MaterialTheme.typography.labelMedium, + ) + Switch( + checked = action?.iconColor == 0, + onCheckedChange = { viewModel.setIconColor(if (it) 0 else 1) }) + } + Row( + modifier = Modifier + .padding(top = 24.dp) + .horizontalScroll(rememberScrollState()) + ) { + OutlinedButton( + modifier = Modifier.padding(start = 16.dp), + onClick = { pickIconLauncher.launch("image/*") }) { + Text(stringResource(R.string.websearch_dialog_replace_icon)) + } + OutlinedButton( + modifier = Modifier.padding(start = 16.dp), + onClick = { viewModel.setIcon(SearchActionIcon.Search) }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.websearch_dialog_delete_icon)) + } + } + } + } +} + +@Composable +private fun SearchActionIconTile( + filled: Boolean = true, + onClick: () -> Unit = {}, + icon: @Composable () -> Unit, +) { + + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .then( + if (filled) Modifier.background(MaterialTheme.colorScheme.surfaceVariant) + else Modifier.border( + 1.dp, + MaterialTheme.colorScheme.outline, + MaterialTheme.shapes.medium + ) + ) + .clickable(onClick = onClick) + ) { + Box(modifier = Modifier.padding(16.dp)) { + icon() + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt new file mode 100644 index 00000000..58029f7b --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt @@ -0,0 +1,277 @@ +package de.mm20.launcher2.ui.settings.searchactions + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.ktx.romanize +import de.mm20.launcher2.searchactions.SearchActionService +import de.mm20.launcher2.searchactions.actions.SearchActionIcon +import de.mm20.launcher2.searchactions.builders.AppSearchActionBuilder +import de.mm20.launcher2.searchactions.builders.CustomIntentActionBuilder +import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder +import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File +import kotlin.math.roundToInt + +class EditSearchActionSheetVM : ViewModel(), KoinComponent { + + private val searchActionService: SearchActionService by inject() + + private var initialCustomIcon: String? = null + + val currentPage = mutableStateOf(EditSearchActionPage.SelectType) + val createNew = mutableStateOf(false) + + val searchAction = mutableStateOf(null) + + fun init(searchAction: CustomizableSearchActionBuilder?) { + initialCustomIcon = searchAction?.customIcon + currentPage.value = when (searchAction) { + is AppSearchActionBuilder -> EditSearchActionPage.CustomizeAppSearch + is WebsearchActionBuilder -> EditSearchActionPage.CustomizeWebSearch + else -> EditSearchActionPage.SelectType + } + createNew.value = searchAction == null + this.searchAction.value = searchAction + } + + fun getSearchableApps(context: Context) = flow { + val items = withContext(Dispatchers.Default) { + searchActionService.getSearchActivities().map { + SearchableApp( + label = context.packageManager.getActivityInfo(it, 0) + .loadLabel(context.packageManager).toString(), + componentName = it + ) + }.sortedBy { it.label.romanize().lowercase() } + } + emit(items) + } + + fun initAppSearch() { + currentPage.value = EditSearchActionPage.InitAppSearch + } + + fun initWebSearch() { + currentPage.value = EditSearchActionPage.InitWebSearch + } + + fun selectSearchableApp(app: SearchableApp) { + searchAction.value = AppSearchActionBuilder( + label = app.label, + componentName = app.componentName, + icon = SearchActionIcon.Custom, + customIcon = null, + iconColor = 1, + ) + currentPage.value = EditSearchActionPage.CustomizeAppSearch + } + + + fun initCustomIntent() { + searchAction.value = CustomIntentActionBuilder( + label = "", + queryKey = "", + baseIntent = Intent(), + icon = SearchActionIcon.Search, + customIcon = null, + iconColor = 0, + ) + currentPage.value = EditSearchActionPage.CustomizeCustomIntent + } + + fun setLabel(label: String) { + val action = searchAction.value ?: return + + val newAction = when (action) { + is CustomIntentActionBuilder -> action.copy(label = label) + is AppSearchActionBuilder -> action.copy(label = label) + is WebsearchActionBuilder -> action.copy(label = label) + } + + searchAction.value = newAction + } + + fun setComponentName(componentName: ComponentName) { + val action = searchAction.value ?: return + + val newAction = when (action) { + is CustomIntentActionBuilder -> action.copy( + baseIntent = action.baseIntent.setComponent( + componentName + ) + ) + + is AppSearchActionBuilder -> action.copy(componentName = componentName) + is WebsearchActionBuilder -> action + } + + searchAction.value = newAction + } + + + val initWebsearchUrl = mutableStateOf("") + /** + * Last imported URL that failed (if the current URL is equal to this, show an error banner) + */ + private val websearchImportErrorUrl = mutableStateOf(null) + val websearchImportError = + derivedStateOf { websearchImportErrorUrl.value == initWebsearchUrl.value } + val loadingWebsearch = mutableStateOf(false) + + val skipWebsearchImport = derivedStateOf { websearchImportError.value || initWebsearchUrl.value.isEmpty() } + + fun importWebsearch(density: Density) { + if (loadingWebsearch.value) return + viewModelScope.launch { + val url = initWebsearchUrl.value + loadingWebsearch.value = true + val action = searchActionService.importWebsearch( + url, + with(density) { 20.dp.toPx().roundToInt() }) + if (action == null) { + websearchImportErrorUrl.value = url + } else { + websearchImportErrorUrl.value = null + searchAction.value = action + currentPage.value = EditSearchActionPage.CustomizeWebSearch + } + loadingWebsearch.value = false + } + } + + fun skipWebsearchImport() { + searchAction.value = WebsearchActionBuilder( + urlTemplate = "", + iconColor = 0, + icon = SearchActionIcon.Search, + label = "", + ) + currentPage.value = EditSearchActionPage.CustomizeWebSearch + } + + fun setUrlTemplate(template: String) { + val action = searchAction.value ?: return + if (action is WebsearchActionBuilder) { + searchAction.value = action.copy( + urlTemplate = template + ) + } + } + + + private val invalidWebsearchUrl = mutableStateOf(null) + val websearchInvalidUrlError = derivedStateOf { invalidWebsearchUrl.value == (searchAction.value as? WebsearchActionBuilder)?.urlTemplate } + fun validate() : Boolean { + val action = searchAction.value ?: return false + + if (action is WebsearchActionBuilder) { + val valid = action.urlTemplate.contains("\${1}") + invalidWebsearchUrl.value = if(valid) null else action.urlTemplate + return valid + } + return true + } + + fun onSave() { + val action = searchAction.value ?: return + if (initialCustomIcon != action.customIcon) deleteCustomIcon(initialCustomIcon) + } + + fun onDismiss() { + val action = searchAction.value ?: return + val newIcon = action.customIcon + if (newIcon != initialCustomIcon) { + deleteCustomIcon(newIcon) + } + } + + fun setIcon(icon: SearchActionIcon) { + val action = searchAction.value ?: return + if (action.customIcon != initialCustomIcon) { + deleteCustomIcon(action.customIcon) + } + searchAction.value = when(action) { + is WebsearchActionBuilder -> action.copy(icon = icon, customIcon = null, iconColor = 0) + is CustomIntentActionBuilder -> action.copy(icon = icon, customIcon = null, iconColor = 0) + is AppSearchActionBuilder -> action.copy(icon = icon, customIcon = null, iconColor = 0) + } + } + + fun setCustomIcon(iconPath: String?) { + val action = searchAction.value ?: return + if (action.customIcon != initialCustomIcon) { + deleteCustomIcon(action.customIcon) + } + searchAction.value = when(action) { + is WebsearchActionBuilder -> action.copy(customIcon = iconPath, iconColor = 1, icon = SearchActionIcon.Custom) + is CustomIntentActionBuilder -> action.copy(customIcon = iconPath, iconColor = 1, icon = SearchActionIcon.Custom) + is AppSearchActionBuilder -> action.copy(customIcon = iconPath, iconColor = 1, icon = SearchActionIcon.Custom) + } + } + + private fun deleteCustomIcon(path: String?) { + path ?: return + viewModelScope.launch(Dispatchers.IO) { + File(path).delete() + } + } + + fun openIconPicker() { + currentPage.value = EditSearchActionPage.PickIcon + } + + fun applyIcon() { + currentPage.value = when(searchAction.value) { + is AppSearchActionBuilder -> EditSearchActionPage.CustomizeAppSearch + is WebsearchActionBuilder -> EditSearchActionPage.CustomizeWebSearch + is CustomIntentActionBuilder -> EditSearchActionPage.CustomizeCustomIntent + null -> EditSearchActionPage.SelectType + } + } + + fun setIconColor(color: Int) { + val action = searchAction.value ?: return + searchAction.value = when(action) { + is WebsearchActionBuilder -> action.copy(iconColor = color) + is CustomIntentActionBuilder -> action.copy(iconColor = color) + is AppSearchActionBuilder -> action.copy(iconColor = color) + } + } + + fun importIcon(uri: Uri, size: Int) { + viewModelScope.launch { + val path = searchActionService.createIcon(uri, size) + setCustomIcon(path) + } + } +} + +enum class EditSearchActionPage { + SelectType, + InitAppSearch, + InitWebSearch, + CustomizeAppSearch, + CustomizeWebSearch, + CustomizeCustomIntent, + PickIcon, +} + +data class SearchableApp( + val label: String, + val componentName: ComponentName, +) \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt index c93fd94f..90403329 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt @@ -69,7 +69,7 @@ fun SearchActionsSettingsScreen() { Scaffold( floatingActionButton = { - FloatingActionButton(onClick = { /*TODO*/ }) { + FloatingActionButton(onClick = { viewModel.createAction() }) { Icon(imageVector = Icons.Rounded.Add, contentDescription = null) } }, @@ -119,13 +119,12 @@ fun SearchActionsSettingsScreen() { if (item is CustomizableSearchActionBuilder) { Preference( icon = { - SearchActionIcon( - icon = item.icon, - color = item.iconColor, - customIcon = item.customIcon - ) + SearchActionIcon(item) }, - title = item.label + title = item.label, + onClick = { + viewModel.editAction(item) + } ) } else { SwitchPreference( @@ -165,4 +164,33 @@ fun SearchActionsSettingsScreen() { } } } + + val editAction by viewModel.showEditDialogFor.observeAsState(null) + val createAction by viewModel.showCreateDialog.observeAsState(false) + + if (createAction) { + EditSearchActionSheet( + initialSearchAction = null, + onSave = { + viewModel.addAction(it) + }, + onDismiss = { + viewModel.dismissDialogs() + } + ) + } + if (editAction != null) { + EditSearchActionSheet( + initialSearchAction = editAction, + onSave = { + viewModel.updateAction(editAction!!, it) + }, + onDismiss = { + viewModel.dismissDialogs() + }, + onDelete = { + viewModel.removeAction(editAction!!) + } + ) + } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt index 32f27f79..f68e2d69 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt @@ -1,11 +1,17 @@ package de.mm20.launcher2.ui.settings.searchactions +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import de.mm20.launcher2.searchactions.SearchActionService +import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder import de.mm20.launcher2.searchactions.builders.SearchActionBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.io.File class SearchActionsSettingsScreenVM : ViewModel(), KoinComponent { private val searchActionService: SearchActionService by inject() @@ -22,11 +28,27 @@ class SearchActionsSettingsScreenVM : ViewModel(), KoinComponent { val actions = searchActions.value?.filter { it.key != searchAction.key }?.plus(searchAction) ?: return searchActionService.saveSearchActionBuilders(actions) + showCreateDialog.value = false } fun removeAction(searchAction: SearchActionBuilder) { val actions = searchActions.value?.filter { it.key != searchAction.key } ?: return searchActionService.saveSearchActionBuilders(actions) + showEditDialogFor.value = null + if(searchAction is CustomizableSearchActionBuilder && searchAction.customIcon != null) { + viewModelScope.launch(Dispatchers.IO) { + File(searchAction.customIcon).delete() + } + } + } + + fun updateAction(old: SearchActionBuilder, new: SearchActionBuilder) { + val actions = + searchActions.value + ?.mapNotNull { if (it.key == old.key) new else if (it.key == new.key) null else it } + ?: return + searchActionService.saveSearchActionBuilders(actions) + showEditDialogFor.value = null } fun moveItem(fromIndex: Int, toIndex: Int) { @@ -37,4 +59,20 @@ class SearchActionsSettingsScreenVM : ViewModel(), KoinComponent { actions.add(toIndex, item) searchActionService.saveSearchActionBuilders(actions) } + + val showEditDialogFor = MutableLiveData(null) + val showCreateDialog = MutableLiveData(false) + + fun editAction(action: CustomizableSearchActionBuilder) { + showEditDialogFor.value = action + } + + fun createAction() { + showCreateDialog.value = true + } + + fun dismissDialogs() { + showCreateDialog.value = false + showEditDialogFor.value = null + } } \ No newline at end of file