Search actions settings [wip]
This commit is contained in:
parent
ca51056bf4
commit
0697068c0c
@ -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)
|
||||
|
||||
|
||||
@ -238,6 +238,8 @@
|
||||
<!-- Close a dialog -->
|
||||
<string name="close">Close</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="skip">Skip</string>
|
||||
<string name="action_continue">Continue</string>
|
||||
<!-- Turn something off / disable a functionality of the launcher -->
|
||||
<string name="turn_off">Turn off</string>
|
||||
<string name="widget_action_adjust_height">Adjust height</string>
|
||||
@ -651,7 +653,7 @@
|
||||
<string name="backup_component_favorites">Favorites & hidden apps</string>
|
||||
<string name="backup_component_settings">Settings</string>
|
||||
<string name="backup_component_websearches">Web search shortcuts</string>
|
||||
<string name="backup_component_searchactions">Web & app search shortcuts</string>
|
||||
<string name="backup_component_searchactions">Quick actions</string>
|
||||
<string name="backup_component_widgets">Built-in widgets</string>
|
||||
<string name="backup_component_customizations">Customizations</string>
|
||||
<string name="backup_complete">The backup has been completed.</string>
|
||||
@ -688,4 +690,19 @@
|
||||
<string name="search_action_contact">Add to contacts</string>
|
||||
<string name="search_action_open_url">View website</string>
|
||||
<string name="search_action_event">Schedule event</string>
|
||||
|
||||
<string name="create_search_action_type">What kind of action do you want to create?</string>
|
||||
<string name="create_search_action_type_web">Search on a website</string>
|
||||
<string name="create_search_action_type_app">Search in an app</string>
|
||||
<string name="create_search_action_type_intent">Custom intent</string>
|
||||
<string name="create_search_action_title">New quick action</string>
|
||||
<string name="edit_search_action_title">Edit quick action</string>
|
||||
<string name="create_search_action_pick_app">Pick an app to search:</string>
|
||||
<string name="create_search_action_website_url">Enter the address of the website:</string>
|
||||
<string name="create_search_action_website_invalid_url">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.</string>
|
||||
<string name="search_action_label">Name</string>
|
||||
<string name="search_action_app">App</string>
|
||||
<string name="search_action_websearch_url">URL template</string>
|
||||
<string name="search_action_websearch_url_hint">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}.</string>
|
||||
<string name="more_information">More information</string>
|
||||
</resources>
|
||||
@ -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<ResolveInfo>
|
||||
suspend fun getSearchActivities(): List<ComponentName>
|
||||
|
||||
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<ResolveInfo> {
|
||||
val packageManager = context.packageManager
|
||||
val intent = Intent(Intent.ACTION_SEARCH)
|
||||
return packageManager.queryIntentActivities(intent, 0)
|
||||
override suspend fun getSearchActivities(): List<ComponentName> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<Drawable?>(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
|
||||
|
||||
@ -47,9 +47,7 @@ fun SearchBarActions(
|
||||
label = { Text(it.label) },
|
||||
leadingIcon = {
|
||||
SearchActionIcon(
|
||||
icon = it.icon,
|
||||
color = it.iconColor,
|
||||
customIcon = it.customIcon
|
||||
action = it
|
||||
)
|
||||
}
|
||||
/*leadingIcon = {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<CustomizableSearchActionBuilder?>(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<String?>(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<String?>(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,
|
||||
)
|
||||
@ -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!!)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<CustomizableSearchActionBuilder?>(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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user