From 0697068c0c6caeba5601c96f02d946bfb4053da7 Mon Sep 17 00:00:00 2001
From: MM20 <15646950+MM2-0@users.noreply.github.com>
Date: Thu, 17 Nov 2022 21:46:20 +0100
Subject: [PATCH] Search actions settings [wip]
---
.../database/migrations/Migration_18_19.kt | 2 +-
i18n/src/main/res/values/strings.xml | 19 +-
.../searchactions/SearchActionService.kt | 37 +-
.../searchactions/actions/AppSearchAction.kt | 7 +-
.../actions/CustomIntentAction.kt | 22 +
.../builders/AppSearchActionBuilder.kt | 7 +-
.../builders/CustomIntentActionBuilder.kt | 26 +
.../builders/SearchActionBuilder.kt | 43 +-
.../builders/WebsearchActionBuilder.kt | 2 +-
.../ui/component/BottomSheetDialog.kt | 38 +-
.../ui/component/SearchActionIcon.kt | 78 +-
.../ui/launcher/searchbar/SearchBarActions.kt | 4 +-
.../searchactions/EditSearchActionSheet.kt | 706 ++++++++++++++++++
.../searchactions/EditSearchActionSheetVM.kt | 277 +++++++
.../SearchActionsSettingsScreen.kt | 42 +-
.../SearchActionsSettingsScreenVM.kt | 38 +
16 files changed, 1306 insertions(+), 42 deletions(-)
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CustomIntentAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CustomIntentActionBuilder.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheet.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt
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