Add more options for custom intent search actions

This commit is contained in:
MM20
2025-09-21 18:02:38 +02:00
parent 960dd51594
commit 4d1bbec175
6 changed files with 262 additions and 79 deletions

View File

@@ -9,6 +9,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -21,6 +22,7 @@ fun <T> ListPreference(
items: List<ListPreferenceItem<T>>,
value: T,
summary: String? = items.firstOrNull { value == it.value }?.label,
containerColor: Color = MaterialTheme.colorScheme.surface,
onValueChanged: (T) -> Unit,
enabled: Boolean = true,
itemLabel: @Composable RowScope.(item: ListPreferenceItem<T>) -> Unit = {
@@ -36,6 +38,7 @@ fun <T> ListPreference(
icon = icon,
iconPadding = iconPadding,
enabled = enabled,
containerColor = containerColor,
onClick = {
showDialog = true
}

View File

@@ -51,6 +51,9 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -65,6 +68,7 @@ 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.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -114,7 +118,9 @@ fun EditSearchActionSheet(
Column(
modifier = when (page) {
EditSearchActionPage.InitAppSearch, EditSearchActionPage.PickIcon -> Modifier
else -> Modifier.verticalScroll(rememberScrollState()).padding(it)
else -> Modifier
.verticalScroll(rememberScrollState())
.padding(it)
}
) {
when (page) {
@@ -184,7 +190,9 @@ fun EditSearchActionSheet(
if (button != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.End,
) {
button()
@@ -400,6 +408,7 @@ fun CustomizeWebSearch(viewModel: EditSearchActionSheetVM) {
value = searchAction!!.label,
onValueChange = { viewModel.setLabel(it) },
label = { Text(stringResource(R.string.search_action_label)) },
singleLine = true,
)
}
@@ -490,7 +499,8 @@ fun CustomizeWebSearch(viewModel: EditSearchActionSheetVM) {
onValueChanged = {
viewModel.setQueryEncoding(it)
},
iconPadding = false
iconPadding = false,
containerColor = Color.Transparent
)
}
}
@@ -538,13 +548,15 @@ fun CustomizeAppSearch(viewModel: EditSearchActionSheetVM) {
value = searchAction!!.label,
onValueChange = { viewModel.setLabel(it) },
label = { Text(stringResource(R.string.search_action_label)) },
singleLine = true,
)
}
var showAppDropdown by remember { mutableStateOf(false) }
Box(modifier = Modifier
.fillMaxWidth()
.clickable { showAppDropdown = !showAppDropdown }) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { showAppDropdown = !showAppDropdown }) {
OutlinedTextFieldDefaults.DecorationBox(
value = selectedApp?.label ?: "",
enabled = true,
@@ -563,7 +575,7 @@ fun CustomizeAppSearch(viewModel: EditSearchActionSheetVM) {
SearchActionIcon(
size = 24.dp,
componentName = selectedApp.componentName,
icon = de.mm20.launcher2.searchactions.actions.SearchActionIcon.Custom,
icon = SearchActionIcon.Custom,
color = 1,
)
}
@@ -586,7 +598,7 @@ fun CustomizeAppSearch(viewModel: EditSearchActionSheetVM) {
SearchActionIcon(
size = 24.dp,
componentName = app.componentName,
icon = de.mm20.launcher2.searchactions.actions.SearchActionIcon.Custom,
icon = SearchActionIcon.Custom,
color = 1,
)
}
@@ -619,81 +631,180 @@ fun CustomizeAppSearch(viewModel: EditSearchActionSheetVM) {
fun CustomizeCustomIntent(viewModel: EditSearchActionSheetVM) {
val searchAction by viewModel.searchAction
val action = searchAction
val action = (searchAction as? CustomIntentActionBuilder) ?: return
if (action is CustomIntentActionBuilder) {
Column(
modifier = Modifier
.fillMaxWidth()
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.Bottom
) {
Row(
verticalAlignment = Alignment.Bottom
) {
SearchActionIconTile(onClick = {
viewModel.openIconPicker()
}) {
SearchActionIcon(
builder = searchAction!!, size = 24.dp
)
}
OutlinedTextField(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
value = action.label,
onValueChange = { viewModel.setLabel(it) },
label = { Text(stringResource(R.string.search_action_label)) },
SearchActionIconTile(onClick = {
viewModel.openIconPicker()
}) {
SearchActionIcon(
builder = searchAction!!, size = 24.dp
)
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
value = action.baseIntent.action ?: "",
onValueChange = { viewModel.setIntentAction(it) },
label = { Text("Action") },
.weight(1f)
.padding(start = 16.dp),
value = action.label,
onValueChange = { viewModel.setLabel(it) },
label = { Text(stringResource(R.string.search_action_label)) },
singleLine = true,
)
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.baseIntent.categories?.firstOrNull() ?: "",
onValueChange = { viewModel.setIntentCategory(it) },
label = { Text("Category") },
)
Text(
text = "Query",
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.queryKey,
onValueChange = { viewModel.setIntentQueryExtra(it) },
label = { Text("Extra key") },
supportingText = {
Text("The key of the string extra that the search term is passed as.")
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
SegmentedButton(
selected = action.queryKey == null,
onClick = {
viewModel.setQueryKey(null)
},
shape = SegmentedButtonDefaults.itemShape(0, 2)
) {
Text("Data")
}
SegmentedButton(
selected = action.queryKey != null,
onClick = {
viewModel.setQueryKey("")
},
shape = SegmentedButtonDefaults.itemShape(1, 2)
) {
Text("String extra")
}
}
if (action.queryKey != null) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.queryKey ?: "",
onValueChange = { viewModel.setQueryKey(it) },
label = { Text("Extra key") },
singleLine = true,
isError = viewModel.customIntentKeyError.value
)
}
var showAdvanced by remember {
mutableStateOf(false)
}
val placeholderBackground = MaterialTheme.colorScheme.tertiary
val placeholderColor = MaterialTheme.colorScheme.onTertiary
AnimatedVisibility(!showAdvanced) {
TextButton(
modifier = Modifier.padding(top = 16.dp),
onClick = { showAdvanced = true }) {
Text(stringResource(id = R.string.websearch_dialog_advanced))
}
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.queryTemplate ?: "",
onValueChange = { viewModel.setIntentQueryTemplate(it) },
label = { Text(if (action.queryKey == null) "Data template" else "String extra template") },
supportingText = {
Text(
if (action.queryKey == null) {
"The URI template that is used to construct the intent\\'s data URI. Use \${1} as a placeholder for the actual search term, e.g. geo:0,0?q=,\${1}"
} else {
"The template that is used to construct the string that is passed to the intent as a string extra. Use \${1} as a placeholder for the actual search term"
}
)
},
singleLine = true,
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)
},
isError = viewModel.customIntentTemplateError.value
)
AnimatedVisibility(showAdvanced) {
IntentExtrasEditor(viewModel)
Text(
text = "Base intent",
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
value = action.baseIntent.action ?: "",
onValueChange = { viewModel.setIntentAction(it) },
label = { Text("Action") },
singleLine = true,
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.baseIntent.categories?.firstOrNull() ?: "",
onValueChange = { viewModel.setIntentCategory(it) },
label = { Text("Category") },
singleLine = true,
)
if (action.queryKey != null) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.baseIntent.dataString ?: "",
onValueChange = { viewModel.setIntentData(it) },
label = { Text("Data") },
singleLine = true,
)
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
value = action.baseIntent.type ?: "",
onValueChange = { viewModel.setIntentType(it) },
label = { Text("Type") },
singleLine = true,
)
var showAdvanced by remember {
mutableStateOf(false)
}
AnimatedVisibility(!showAdvanced) {
TextButton(
modifier = Modifier.padding(top = 16.dp),
onClick = { showAdvanced = true }) {
Text(stringResource(id = R.string.websearch_dialog_advanced))
}
}
AnimatedVisibility(showAdvanced) {
IntentExtrasEditor(viewModel)
}
}
}
@@ -765,7 +876,9 @@ fun PickIcon(viewModel: EditSearchActionSheetVM, paddingValues: PaddingValues) {
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(paddingValues).fillMaxWidth()
modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
) {
SearchActionIconTile {

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.romanize
@@ -16,8 +17,8 @@ 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.CustomWebsearchActionBuilder
import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
@@ -182,6 +183,7 @@ class EditSearchActionSheetVM : ViewModel(), KoinComponent {
private val invalidWebsearchUrl = mutableStateOf<String?>(null)
val websearchInvalidUrlError =
derivedStateOf { invalidWebsearchUrl.value == (searchAction.value as? CustomWebsearchActionBuilder)?.urlTemplate }
val customIntentTemplateError = mutableStateOf(false)
val customIntentKeyError = mutableStateOf(false)
fun validate(): Boolean {
val action = searchAction.value ?: return false
@@ -192,9 +194,9 @@ class EditSearchActionSheetVM : ViewModel(), KoinComponent {
return valid
}
if (action is CustomIntentActionBuilder) {
val valid = action.queryKey.isNotBlank()
customIntentKeyError.value = !valid
return valid
customIntentTemplateError.value = !(action.queryTemplate.isNullOrEmpty() || action.queryTemplate!!.contains("\${1}"))
customIntentKeyError.value = action.queryKey != null && action.queryKey!!.isEmpty()
return !customIntentTemplateError.value && !customIntentKeyError.value
}
return true
}
@@ -218,7 +220,12 @@ class EditSearchActionSheetVM : ViewModel(), KoinComponent {
deleteCustomIcon(action.customIcon)
}
searchAction.value = when (action) {
is CustomWebsearchActionBuilder -> action.copy(icon = icon, customIcon = null, iconColor = 0)
is CustomWebsearchActionBuilder -> action.copy(
icon = icon,
customIcon = null,
iconColor = 0
)
is CustomIntentActionBuilder -> action.copy(
icon = icon,
customIcon = null,
@@ -484,7 +491,37 @@ class EditSearchActionSheetVM : ViewModel(), KoinComponent {
}
}
fun setIntentQueryExtra(key: String) {
fun setIntentType(type: String) {
val searchAction = searchAction.value ?: return
this.searchAction.value = when (searchAction) {
is CustomIntentActionBuilder -> searchAction.copy(
baseIntent = searchAction.baseIntent.cloneFilter().also {
val extras = searchAction.baseIntent.extras?.deepCopy() ?: Bundle()
it.replaceExtras(extras)
it.setDataAndType(it.data, type.takeIf { it.isNotBlank() })
},
)
else -> searchAction
}
}
fun setIntentData(data: String) {
val searchAction = searchAction.value ?: return
this.searchAction.value = when (searchAction) {
is CustomIntentActionBuilder -> searchAction.copy(
baseIntent = searchAction.baseIntent.cloneFilter().also {
val extras = searchAction.baseIntent.extras?.deepCopy() ?: Bundle()
it.replaceExtras(extras)
it.setDataAndType(data.takeIf { it.isNotEmpty() }?.toUri(), it.type)
},
)
else -> searchAction
}
}
fun setQueryKey(key: String?) {
val action = searchAction.value ?: return
searchAction.value = when (action) {
is CustomIntentActionBuilder -> action.copy(
@@ -495,6 +532,17 @@ class EditSearchActionSheetVM : ViewModel(), KoinComponent {
}
}
fun setIntentQueryTemplate(template: String) {
val action = searchAction.value ?: return
searchAction.value = when (action) {
is CustomIntentActionBuilder -> action.copy(
queryTemplate = template.takeIf { it.isNotEmpty() },
)
else -> action
}
}
fun setQueryEncoding(encoding: CustomWebsearchActionBuilder.QueryEncoding) {
val action = searchAction.value ?: return
searchAction.value = when (action) {

View File

@@ -2,20 +2,33 @@ package de.mm20.launcher2.searchactions.actions
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import de.mm20.launcher2.ktx.tryStartActivity
class CustomIntentAction(
override val label: String,
val query: String,
private val queryKey: String,
private val queryKey: String?,
private val queryTemplate: String? = null,
private 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 query = if (queryTemplate.isNullOrBlank()) {
query
} else {
queryTemplate.replace("\${1}", query)
}
val intent = Intent(baseIntent).also {
it.putExtra(queryKey, query)
if (queryKey == null) {
it.setDataAndType(query.toUri(), baseIntent.type)
} else {
it.putExtra(queryKey, query)
}
}
context.tryStartActivity(intent)
}

View File

@@ -9,7 +9,11 @@ import de.mm20.launcher2.searchactions.actions.SearchActionIcon
data class CustomIntentActionBuilder(
override val label: String,
val queryKey: String,
/**
* The extra of the intent to put the query in. If null, the query will be passed in the data URI.
*/
val queryKey: String?,
val queryTemplate: String? = null,
val baseIntent: Intent,
override val icon: SearchActionIcon = SearchActionIcon.Custom,
override val iconColor: Int = 1,
@@ -20,7 +24,7 @@ data class CustomIntentActionBuilder(
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
return CustomIntentAction(
label, classifiedQuery.text, queryKey, baseIntent, icon, iconColor, customIcon
label, classifiedQuery.text, queryKey, queryTemplate, baseIntent, icon, iconColor, customIcon
)
}
}

View File

@@ -57,7 +57,8 @@ interface SearchActionBuilder {
iconColor = entity.color ?: 0,
icon = SearchActionIcon.fromInt(entity.icon),
customIcon = entity.customIcon,
queryKey = options?.getString("extra")?.takeIf { it.isNotEmpty() } ?: return null
queryKey = options?.optString("extra"),
queryTemplate = options?.optString("template")?.takeIf { it.isNotBlank() }
)
}
"call" -> return CallActionBuilder(context)
@@ -107,7 +108,8 @@ interface SearchActionBuilder {
icon = builder.icon.toInt(),
customIcon = builder.customIcon,
options = jsonObjectOf(
"extra" to builder.queryKey
"extra" to builder.queryKey,
"template" to builder.queryTemplate,
).toString()
)
else -> SearchActionEntity(