Make search action order customizable

This commit is contained in:
MM20 2022-11-05 17:59:09 +01:00
parent 960c3b70f5
commit 3463b3b800
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
29 changed files with 526 additions and 253 deletions

View File

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "c6adf1dca8ade5117d4e8952a67786fa",
"identityHash": "968a73b13f39e00505a4adf1bda01394",
"entities": [
{
"tableName": "forecasts",
@ -398,7 +398,7 @@
},
{
"tableName": "SearchAction",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, `label` TEXT, `icon` INTEGER NOT NULL, `color` INTEGER NOT NULL, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT, `label` TEXT, `icon` INTEGER, `color` INTEGER, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))",
"fields": [
{
"fieldPath": "position",
@ -416,7 +416,7 @@
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
"notNull": false
},
{
"fieldPath": "label",
@ -428,13 +428,13 @@
"fieldPath": "icon",
"columnName": "icon",
"affinity": "INTEGER",
"notNull": true
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": true
"notNull": false
},
{
"fieldPath": "customIcon",
@ -462,7 +462,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c6adf1dca8ade5117d4e8952a67786fa')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '968a73b13f39e00505a4adf1bda01394')"
]
}
}

View File

@ -57,13 +57,23 @@ abstract class AppDatabase : RoomDatabase() {
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
db.execSQL("INSERT INTO `SearchAction` (`position`, `type`) VALUES" +
"(0, 'call')," +
"(1, 'message')," +
"(2, 'email')," +
"(3, 'contact')," +
"(4, 'alarm')," +
"(5, 'timer')," +
"(6, 'calendar')," +
"(7, 'website')"
)
db.execSQL("INSERT INTO `SearchAction` (`position`, `type`, `data`, `label`, `color`, `icon`, `customIcon`, `options`) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(
0, "url", context.getString(R.string.default_websearch_1_url), context.getString(R.string.default_websearch_1_name), 0, 0, null, null,
0, "url", context.getString(R.string.default_websearch_2_url), context.getString(R.string.default_websearch_2_name), 0, 0, null, null,
0, "url", context.getString(R.string.default_websearch_3_url), context.getString(R.string.default_websearch_3_name), 0, 0, null, null,
8, "url", context.getString(R.string.default_websearch_1_url), context.getString(R.string.default_websearch_1_name), 0, 0, null, null,
9, "url", context.getString(R.string.default_websearch_2_url), context.getString(R.string.default_websearch_2_name), 0, 0, null, null,
10, "url", context.getString(R.string.default_websearch_3_url), context.getString(R.string.default_websearch_3_name), 0, 0, null, null,
)
)

View File

@ -1,7 +1,9 @@
package de.mm20.launcher2.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import de.mm20.launcher2.database.entities.SearchActionEntity
import kotlinx.coroutines.flow.Flow
@ -9,4 +11,16 @@ import kotlinx.coroutines.flow.Flow
interface SearchActionDao {
@Query("SELECT * FROM SearchAction ORDER BY position ASC")
fun getSearchActions(): Flow<List<SearchActionEntity>>
@Transaction
suspend fun replaceAll(actions: List<SearchActionEntity>) {
deleteAll()
insertAll(actions)
}
@Query("DELETE FROM `SearchAction`")
suspend fun deleteAll()
@Insert
suspend fun insertAll(actions: List<SearchActionEntity>)
}

View File

@ -7,10 +7,10 @@ import androidx.room.PrimaryKey
data class SearchActionEntity(
@PrimaryKey val position: Int,
val type: String,
val data: String,
val label: String?,
val icon: Int,
val color: Int = 0,
val data: String? = null,
val label: String? = null,
val icon: Int? = null,
val color: Int? = null,
val customIcon: String? = null,
val options: String? = null,
)

View File

@ -9,9 +9,19 @@ class Migration_18_19 : Migration(18, 19) {
override fun migrate(database: SupportSQLiteDatabase) {
val websearches =
database.query("SELECT label, urlTemplate, color, icon, encoding FROM `Websearch` ORDER BY label ASC")
database.execSQL("CREATE TABLE IF NOT EXISTS `SearchAction` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, `label` TEXT, `icon` INTEGER NOT NULL, `color` INTEGER NOT NULL, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))"
database.execSQL("CREATE TABLE IF NOT EXISTS `SearchAction` (`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `data` TEXT, `label` TEXT, `icon` INTEGER, `color` INTEGER, `customIcon` TEXT, `options` TEXT, PRIMARY KEY(`position`))"
)
var position = 0
database.execSQL("INSERT INTO `SearchAction` (`position`, `type`) VALUES" +
"(0, 'call')," +
"(1, 'message')," +
"(2, 'email')," +
"(3, 'contact')," +
"(4, 'alarm')," +
"(5, 'timer')," +
"(6, 'calendar')," +
"(7, 'website')"
)
var position = 8
while (websearches.moveToNext()) {
val label = websearches.getString(0)
val data = websearches.getString(1)

View File

@ -22,7 +22,7 @@ internal val Context.dataStore: LauncherDataStore by dataStore(
}
)
internal const val SchemaVersion = 12
internal const val SchemaVersion = 11
internal fun getMigrations(context: Context): List<DataMigration<Settings>> {
return listOf(
@ -37,6 +37,5 @@ internal fun getMigrations(context: Context): List<DataMigration<Settings>> {
Migration_8_9(),
Migration_9_10(),
Migration_10_11(),
Migration_11_12(),
)
}

View File

@ -162,17 +162,6 @@ fun createFactorySettings(context: Context): Settings {
Settings.WidgetSettings.newBuilder()
.setEditButton(true)
)
.setSearchActions(
Settings.SearchActionSettings.newBuilder()
.setCall(true)
.setContact(true)
.setEmail(true)
.setMessage(true)
.setOpenUrl(true)
.setScheduleEvent(true)
.setSetAlarm(true)
.setStartTimer(true)
)
.build()
}

View File

@ -288,16 +288,4 @@ message Settings {
bool edit_button = 1;
}
WidgetSettings widgets = 26;
message SearchActionSettings {
bool call = 1;
bool message = 2;
bool email = 3;
bool contact = 4;
bool open_url = 5;
bool schedule_event = 6;
bool set_alarm = 7;
bool start_timer = 8;
}
SearchActionSettings search_actions = 27;
}

View File

@ -5,10 +5,21 @@ import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.builders.CallActionBuilder
import de.mm20.launcher2.searchactions.builders.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder
import de.mm20.launcher2.searchactions.builders.MessageActionBuilder
import de.mm20.launcher2.searchactions.builders.OpenUrlActionBuilder
import de.mm20.launcher2.searchactions.builders.ScheduleEventActionBuilder
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.SetAlarmActionBuilder
import de.mm20.launcher2.searchactions.builders.TimerActionBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
@ -17,6 +28,9 @@ import java.util.UUID
interface SearchActionRepository {
fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>>
fun getBuiltinSearchActionBuilders(): List<SearchActionBuilder>
fun saveSearchActionBuilders(builders: List<SearchActionBuilder>)
suspend fun export(toDir: File)
suspend fun import(fromDir: File)
@ -26,9 +40,35 @@ internal class SearchActionRepositoryImpl(
private val context: Context,
private val database: AppDatabase
): SearchActionRepository {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>> {
val dao = database.searchActionDao()
return dao.getSearchActions().map { it.mapNotNull { SearchActionBuilder.from(it) } }
return dao.getSearchActions().map { it.mapNotNull { SearchActionBuilder.from(context, it) } }
}
override fun getBuiltinSearchActionBuilders(): List<SearchActionBuilder> {
val allActions = listOf(
CallActionBuilder(context),
MessageActionBuilder(context),
CreateContactActionBuilder(context),
EmailActionBuilder(context),
ScheduleEventActionBuilder(context),
SetAlarmActionBuilder(context),
TimerActionBuilder(context),
OpenUrlActionBuilder(context),
)
return allActions
}
override fun saveSearchActionBuilders(builders: List<SearchActionBuilder>) {
scope.launch {
val dao = database.searchActionDao()
dao.replaceAll(
builders.mapIndexed { i, it -> SearchActionBuilder.toDatabaseEntity(it, i) }
)
}
}
override suspend fun export(toDir: File) = withContext(Dispatchers.IO) {

View File

@ -2,13 +2,9 @@ package de.mm20.launcher2.searchactions
import android.content.Context
import android.content.Intent
import android.content.pm.LauncherActivityInfo
import android.content.pm.LauncherApps
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
import android.net.Uri
import android.os.UserHandle
import android.util.Log
import android.util.Xml
import androidx.core.graphics.drawable.toBitmap
@ -16,20 +12,10 @@ import coil.imageLoader
import coil.request.ImageRequest
import coil.size.Scale
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.database.entities.WebsearchEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
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.CreateContactActionBuilder
import de.mm20.launcher2.searchactions.builders.EmailActionBuilder
import de.mm20.launcher2.searchactions.builders.MessageActionBuilder
import de.mm20.launcher2.searchactions.builders.OpenUrlActionBuilder
import de.mm20.launcher2.searchactions.builders.ScheduleEventActionBuilder
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import de.mm20.launcher2.searchactions.builders.SetAlarmActionBuilder
import de.mm20.launcher2.searchactions.builders.TimerActionBuilder
import de.mm20.launcher2.searchactions.builders.WebsearchActionBuilder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -42,8 +28,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONException
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
@ -54,12 +38,18 @@ import java.net.URL
import java.util.UUID
interface SearchActionService {
fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>>
fun search(query: String): Flow<ImmutableList<SearchAction>>
fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>>
fun getDisabledActionBuilders(): Flow<List<SearchActionBuilder>>
fun saveSearchActionBuilders(builders: List<SearchActionBuilder>)
suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder?
suspend fun createIcon(uri: Uri, size: Int): String?
suspend fun getSearchActivities(): List<ResolveInfo>
suspend fun createIcon(uri: Uri, size: Int): String?
}
internal class SearchActionServiceImpl(
@ -68,7 +58,6 @@ internal class SearchActionServiceImpl(
private val textClassifier: TextClassifier,
) : SearchActionService {
override fun search(
settings: SearchActionSettings,
query: String
): Flow<ImmutableList<SearchAction>> = flow {
if (query.isBlank()) {
@ -76,28 +65,33 @@ internal class SearchActionServiceImpl(
return@flow
}
val builders = mutableListOf<SearchActionBuilder>()
if (settings.call) builders.add(CallActionBuilder)
if (settings.message) builders.add(MessageActionBuilder)
if (settings.contact) builders.add(CreateContactActionBuilder)
if (settings.email) builders.add(EmailActionBuilder)
if (settings.openUrl) builders.add(OpenUrlActionBuilder)
if (settings.scheduleEvent) builders.add(ScheduleEventActionBuilder)
if (settings.setAlarm) builders.add(SetAlarmActionBuilder)
if (settings.startTimer) builders.add(TimerActionBuilder)
val classificationResult = textClassifier.classify(context, query)
val other = repository.getSearchActionBuilders()
val builders = repository.getSearchActionBuilders()
emitAll(
other.map {
(builders + it).mapNotNull { it.build(context, classificationResult) }.toImmutableList()
builders.map {
it.mapNotNull { it.build(context, classificationResult) }.toImmutableList()
}
)
}
override fun getSearchActionBuilders(): Flow<List<SearchActionBuilder>> {
return repository.getSearchActionBuilders()
}
override fun getDisabledActionBuilders(): Flow<List<SearchActionBuilder>> {
val allActions = repository.getBuiltinSearchActionBuilders()
return getSearchActionBuilders().map { enabled ->
allActions.filter { action -> !enabled.any { it.key == action.key } }
}
}
override fun saveSearchActionBuilders(builders: List<SearchActionBuilder>) {
repository.saveSearchActionBuilders(builders)
}
override suspend fun importWebsearch(url: String, iconSize: Int): WebsearchActionBuilder? =
withContext(Dispatchers.IO) {
try {

View File

@ -1,21 +1,27 @@
package de.mm20.launcher2.searchactions.builders
import android.content.ComponentName
import android.content.Context
import android.content.pm.LauncherActivityInfo
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.TextType
import de.mm20.launcher2.searchactions.actions.AppSearchAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
class AppSearchActionBuilder(
val label: String,
val activity: LauncherActivityInfo,
val filter: TextType? = null,
) : SearchActionBuilder {
override val label: String,
val componentName: ComponentName,
override val icon: SearchActionIcon = SearchActionIcon.Search,
override val iconColor: Int = 0,
override val customIcon: String? = null,
) : CustomizableSearchActionBuilder {
override val key: String
get() = "app://${componentName.flattenToShortString()}"
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
return AppSearchAction(
label = label,
componentName = activity.componentName,
componentName = componentName,
query = classifiedQuery.text,
)
}

View File

@ -5,8 +5,18 @@ import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.CallAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
object CallActionBuilder: SearchActionBuilder {
class CallActionBuilder(
override val label: String
): SearchActionBuilder {
constructor(context: Context): this(context.getString(R.string.search_action_call))
override val key: String
get() = "call"
override val icon: SearchActionIcon = SearchActionIcon.Phone
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.phoneNumber != null) {

View File

@ -5,8 +5,18 @@ import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.CreateContactAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
object CreateContactActionBuilder : SearchActionBuilder {
class CreateContactActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_contact))
override val key: String
get() = "contact"
override val icon: SearchActionIcon = SearchActionIcon.Contact
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.phoneNumber != null || classifiedQuery.email != null) {

View File

@ -0,0 +1,3 @@
package de.mm20.launcher2.searchactions.builders
sealed interface CustomizableSearchActionBuilder: SearchActionBuilder

View File

@ -5,8 +5,18 @@ import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.EmailAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
object EmailActionBuilder: SearchActionBuilder {
class EmailActionBuilder(
override val label: String
): SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_email))
override val key: String
get() = "email"
override val icon: SearchActionIcon = SearchActionIcon.Email
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.email != null) {

View File

@ -5,8 +5,18 @@ import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.MessageAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
object MessageActionBuilder: SearchActionBuilder {
class MessageActionBuilder(
override val label: String
): SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_message))
override val key: String
get() = "message"
override val icon: SearchActionIcon = SearchActionIcon.Message
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.phoneNumber != null) {

View File

@ -6,8 +6,18 @@ import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.MessageAction
import de.mm20.launcher2.searchactions.actions.OpenUrlAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
object OpenUrlActionBuilder : SearchActionBuilder {
class OpenUrlActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_open_url))
override val key: String
get() = "website"
override val icon: SearchActionIcon = SearchActionIcon.Website
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.url != null) {

View File

@ -6,9 +6,19 @@ import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.MessageAction
import de.mm20.launcher2.searchactions.actions.ScheduleEventAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import java.time.LocalDateTime
object ScheduleEventActionBuilder : SearchActionBuilder {
class ScheduleEventActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_event))
override val key: String
get() = "calendar"
override val icon: SearchActionIcon = SearchActionIcon.Calendar
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.date != null) {

View File

@ -1,7 +1,9 @@
package de.mm20.launcher2.searchactions.builders
import android.content.Context
import android.media.metrics.Event
import de.mm20.launcher2.database.entities.SearchActionEntity
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
@ -9,10 +11,18 @@ import org.json.JSONException
import org.json.JSONObject
interface SearchActionBuilder {
val label: String
val icon: SearchActionIcon
val iconColor: Int
get() = 0
val customIcon: String?
get() = null
val key: String
fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction?
companion object {
fun from(entity: SearchActionEntity): SearchActionBuilder? {
internal fun from(context: Context, entity: SearchActionEntity): SearchActionBuilder? {
val options = entity.options?.let {
try {
JSONObject(it)
@ -24,8 +34,8 @@ interface SearchActionBuilder {
"url" -> {
return WebsearchActionBuilder(
label = entity.label ?: "",
urlTemplate = entity.data,
color = entity.color,
urlTemplate = entity.data ?: return null,
iconColor = entity.color ?: 0,
icon = SearchActionIcon.fromInt(entity.icon),
customIcon = entity.customIcon,
encoding = WebsearchActionBuilder.QueryEncoding.fromInt(options?.optInt("encoding"))
@ -34,9 +44,38 @@ interface SearchActionBuilder {
"app" -> {
return null
}
"call" -> return CallActionBuilder(context)
"message" -> return MessageActionBuilder(context)
"email" -> return EmailActionBuilder(context)
"contact" -> return CreateContactActionBuilder(context)
"alarm" -> return SetAlarmActionBuilder(context)
"timer" -> return TimerActionBuilder(context)
"calendar" -> return ScheduleEventActionBuilder(context)
"website" -> return OpenUrlActionBuilder(context)
else -> return null
}
}
}
internal fun toDatabaseEntity(builder: SearchActionBuilder, position: Int): SearchActionEntity {
return when(builder) {
is WebsearchActionBuilder -> SearchActionEntity(
position = position,
type = "url",
label = builder.label,
data = builder.urlTemplate,
color = builder.iconColor,
icon = builder.icon.toInt(),
customIcon = builder.customIcon,
options = jsonObjectOf(
"encoding" to builder.encoding.toInt()
).toString()
)
//is AppSearchActionBuilder -> null
else -> SearchActionEntity(
position = position,
type = builder.key,
)
}
}
}
}

View File

@ -4,10 +4,20 @@ import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.actions.SetAlarmAction
import java.time.LocalDate
object SetAlarmActionBuilder : SearchActionBuilder {
class SetAlarmActionBuilder(
override val label: String
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_alarm))
override val key: String
get() = "alarm"
override val icon: SearchActionIcon = SearchActionIcon.Alarm
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.time != null) {

View File

@ -3,10 +3,19 @@ package de.mm20.launcher2.searchactions.builders
import android.content.Context
import de.mm20.launcher2.searchactions.R
import de.mm20.launcher2.searchactions.TextClassificationResult
import de.mm20.launcher2.searchactions.actions.TimerAction
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.searchactions.actions.TimerAction
object TimerActionBuilder : SearchActionBuilder {
class TimerActionBuilder(
override val label: String,
) : SearchActionBuilder {
constructor(context: Context) : this(context.getString(R.string.search_action_timer))
override val key: String
get() = "timer"
override val icon: SearchActionIcon = SearchActionIcon.Timer
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
if (classifiedQuery.timespan != null && classifiedQuery.timespan.seconds <= 86400) {

View File

@ -9,13 +9,16 @@ import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import java.net.URLEncoder
class WebsearchActionBuilder(
val label: String,
override val label: String,
val urlTemplate: String,
val icon: SearchActionIcon = SearchActionIcon.Search,
val color: Int = 0,
val customIcon: String? = null,
override val icon: SearchActionIcon = SearchActionIcon.Search,
override val iconColor: Int = 0,
override val customIcon: String? = null,
val encoding: QueryEncoding = QueryEncoding.UrlEncode,
) : SearchActionBuilder {
) : CustomizableSearchActionBuilder {
override val key: String
get() = "web://$urlTemplate"
override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction {
val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
@ -24,7 +27,7 @@ class WebsearchActionBuilder(
url = url,
icon = icon,
customIcon = customIcon,
iconColor = color,
iconColor = iconColor,
)
}

View File

@ -13,7 +13,6 @@ import de.mm20.launcher2.preferences.Settings.CalculatorSearchSettings
import de.mm20.launcher2.preferences.Settings.CalendarSearchSettings
import de.mm20.launcher2.preferences.Settings.ContactsSearchSettings
import de.mm20.launcher2.preferences.Settings.FilesSearchSettings
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
import de.mm20.launcher2.preferences.Settings.UnitConverterSearchSettings
import de.mm20.launcher2.preferences.Settings.WebsiteSearchSettings
import de.mm20.launcher2.preferences.Settings.WikipediaSearchSettings
@ -59,7 +58,6 @@ interface SearchService {
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
searchActions: SearchActionSettings,
): Flow<ImmutableList<Searchable>>
}
@ -87,7 +85,6 @@ internal class SearchServiceImpl(
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
searchActions: SearchActionSettings,
): Flow<ImmutableList<Searchable>> = channelFlow {
supervisorScope {
val results = MutableStateFlow(SearchResults())
@ -220,7 +217,7 @@ internal class SearchServiceImpl(
}
}
launch {
searchActionService.search(searchActions, query)
searchActionService.search(query)
.collectLatest { r ->
results.update {
it.copy(searchActions = r)

View File

@ -0,0 +1,54 @@
package de.mm20.launcher2.ui.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.Call
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.Search
import androidx.compose.material.icons.rounded.Sms
import androidx.compose.material.icons.rounded.Timer
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.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
@Composable
fun SearchActionIcon(
icon: SearchActionIcon,
color: Int,
customIcon: String? = null
) {
val tint = when(color) {
0 -> MaterialTheme.colorScheme.primary
1 -> Color.Unspecified
else -> Color(color)
}
if (icon != SearchActionIcon.Custom) {
Icon(
imageVector = getSearchActionIconVector(icon),
contentDescription = null,
tint = tint,
)
}
}
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.Email -> Icons.Rounded.Email
SearchActionIcon.Message -> Icons.Rounded.Sms
SearchActionIcon.Calendar -> Icons.Rounded.Event
SearchActionIcon.Translate -> Icons.Rounded.Translate
else -> Icons.Rounded.Search
}
}

View File

@ -88,7 +88,7 @@ data class LazyDragAndDropListState(
fun startDrag(offset: Offset): Boolean {
val draggedItem = listState.layoutInfo.visibleItemsInfo.find {
Rect(
it.offset.toOffset(),
(it.offset + listState.layoutInfo.beforeContentPadding).toOffset(),
it.size.toSize()
).contains(offset)
} ?: return false
@ -240,13 +240,16 @@ fun LazyDragAndDropRow(
verticalAlignment: Alignment.Vertical = Alignment.Top,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
bidirectionalDrag: Boolean = true,
content: LazyListScope.() -> Unit
) {
LazyRow(
modifier = modifier.dragAndDrop(
state,
LocalLayoutDirection.current == LayoutDirection.Rtl,
LocalHapticFeedback.current
isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
hapticFeedback = LocalHapticFeedback.current,
dragVertical = bidirectionalDrag,
dragHorizontal = true,
),
state = state.listState,
contentPadding = contentPadding,
@ -269,13 +272,16 @@ fun LazyDragAndDropColumn(
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
bidirectionalDrag: Boolean = true,
content: LazyListScope.() -> Unit
) {
LazyColumn(
modifier = modifier.dragAndDrop(
state,
LocalLayoutDirection.current == LayoutDirection.Rtl,
LocalHapticFeedback.current
isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
hapticFeedback = LocalHapticFeedback.current,
dragVertical = true,
dragHorizontal = bidirectionalDrag,
),
state = state.listState,
contentPadding = contentPadding,
@ -291,6 +297,8 @@ fun LazyDragAndDropColumn(
fun Modifier.dragAndDrop(
state: LazyDragAndDropListState,
isRtl: Boolean,
dragVertical: Boolean = true,
dragHorizontal: Boolean = true,
hapticFeedback: HapticFeedback
) =
this then pointerInput(null) {
@ -302,9 +310,18 @@ fun Modifier.dragAndDrop(
}
},
onDrag = { _, dragAmount ->
scope.launch { state.drag(dragAmount.let {
if (isRtl) it.copy(x = -it.x) else it
}) }
scope.launch {
state.drag(
when {
!dragVertical && !dragHorizontal -> Offset.Zero
dragVertical && !dragHorizontal -> Offset(0f, dragAmount.y)
!dragVertical && dragHorizontal && isRtl -> Offset(-dragAmount.x, 0f)
!dragVertical && dragHorizontal && !isRtl -> Offset(dragAmount.x, 0f)
dragVertical && dragHorizontal && isRtl -> Offset(-dragAmount.x, dragAmount.y)
else -> dragAmount
}
)
}
},
onDragCancel = {
scope.launch { state.cancelDrag() }

View File

@ -95,7 +95,6 @@ class SearchVM : ViewModel(), KoinComponent {
shortcuts = it.appShortcutSearch,
websites = it.websiteSearch,
wikipedia = it.wikipediaSearch,
searchActions = it.searchActions,
).collectLatest { results ->
hiddenItemKeys.collectLatest { hiddenKeys ->
val hidden = mutableListOf<SavableSearchable>()

View File

@ -6,17 +6,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.Call
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.Search
import androidx.compose.material.icons.rounded.Sms
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material.icons.rounded.Translate
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -28,7 +17,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.actions.SearchActionIcon
import de.mm20.launcher2.ui.component.SearchActionIcon
@Composable
fun SearchBarActions(
@ -53,27 +42,7 @@ fun SearchBarActions(
},
label = { Text(it.label) },
leadingIcon = {
val icon = it.icon
if (it.icon != SearchActionIcon.Custom) {
Icon(
imageVector = when (it.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.Email -> Icons.Rounded.Email
SearchActionIcon.Message -> Icons.Rounded.Sms
SearchActionIcon.Calendar -> Icons.Rounded.Event
SearchActionIcon.Translate -> Icons.Rounded.Translate
else -> Icons.Rounded.Search
},
contentDescription = null,
tint = if (it.iconColor == 0) MaterialTheme.colorScheme.primary else Color(
it.iconColor
)
)
}
SearchActionIcon(icon = it.icon, color = it.iconColor, customIcon = it.customIcon)
}
/*leadingIcon = {
val icon = it.icon

View File

@ -1,115 +1,166 @@
package de.mm20.launcher2.ui.settings.searchactions
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.CalendarToday
import androidx.compose.material.icons.rounded.Call
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.Sms
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.Settings
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.searchactions.builders.CustomizableSearchActionBuilder
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.SearchActionIcon
import de.mm20.launcher2.ui.component.getSearchActionIconVector
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.launcher.helper.DraggableItem
import de.mm20.launcher2.ui.launcher.helper.LazyDragAndDropColumn
import de.mm20.launcher2.ui.launcher.helper.rememberLazyDragAndDropListState
import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun SearchActionsSettingsScreen() {
val viewModel: SearchActionsSettingsScreenVM = viewModel()
val settings by viewModel.searchActionSettings.observeAsState(
Settings.SearchActionSettings.getDefaultInstance()
val navController = LocalNavController.current
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.surface)
systemUiController.setNavigationBarColor(Color.Black)
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
val activity = LocalContext.current as? AppCompatActivity
val listState = rememberLazyDragAndDropListState(
onDragStart = {
it.key != "divider" && !(it.key as String).startsWith("disabled-")
},
onItemMove = { from, to -> viewModel.moveItem(from.index, to.index) }
)
PreferenceScreen(stringResource(id = R.string.preference_screen_search_actions)) {
item {
PreferenceCategory {
SwitchPreference(
icon = Icons.Rounded.Call,
title = stringResource(R.string.search_action_call),
value = settings.call,
onValueChanged = {
viewModel.updateSettings {
setCall(it)
val searchActions by viewModel.searchActions.observeAsState(emptyList())
val disabledActions by viewModel.disabledActions.observeAsState(emptyList())
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Rounded.Add, contentDescription = null)
}
},
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
stringResource(id = R.string.preference_screen_search_actions),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1
)
},
navigationIcon = {
IconButton(onClick = {
if (navController?.navigateUp() != true) {
activity?.onBackPressed()
}
},
}) {
Icon(imageVector = Icons.Rounded.ArrowBack, contentDescription = "Back")
}
},
)
}) {
LazyDragAndDropColumn(
state = listState,
bidirectionalDrag = false,
contentPadding = it,
modifier = Modifier
.fillMaxSize()
) {
items(
items = searchActions,
key = { it.key }
) { item ->
DraggableItem(
state = listState,
key = item.key
) {
val elevation by animateDpAsState(if (it) 4.dp else 0.dp)
Surface(
shadowElevation = elevation,
tonalElevation = elevation,
modifier = Modifier.zIndex(if (it) 1f else 0f)
) {
if (item is CustomizableSearchActionBuilder) {
Preference(
icon = {
SearchActionIcon(
icon = item.icon,
color = item.iconColor,
customIcon = item.customIcon
)
},
title = item.label
)
} else {
SwitchPreference(
icon = getSearchActionIconVector(item.icon),
title = item.label,
value = true,
onValueChanged = {
viewModel.removeAction(item)
}
)
}
}
}
}
item(key = "divider") {
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
)
}
items(
items = disabledActions,
key = { "disabled-${it.key}" }
) { item ->
SwitchPreference(
icon = Icons.Rounded.Sms,
title = stringResource(R.string.search_action_message),
value = settings.message,
icon = getSearchActionIconVector(item.icon),
title = item.label,
value = false,
onValueChanged = {
viewModel.updateSettings {
setMessage(it)
}
},
)
SwitchPreference(
icon = Icons.Rounded.Email,
title = stringResource(R.string.search_action_email),
value = settings.email,
onValueChanged = {
viewModel.updateSettings {
setEmail(it)
}
},
)
SwitchPreference(
icon = Icons.Rounded.Person,
title = stringResource(R.string.search_action_contact),
value = settings.contact,
onValueChanged = {
viewModel.updateSettings {
setContact(it)
}
},
)
SwitchPreference(
icon = Icons.Rounded.Alarm,
title = stringResource(R.string.search_action_alarm),
value = settings.setAlarm,
onValueChanged = {
viewModel.updateSettings {
setSetAlarm(it)
}
},
)
SwitchPreference(
icon = Icons.Rounded.Timer,
title = stringResource(R.string.search_action_timer),
value = settings.startTimer,
onValueChanged = {
viewModel.updateSettings {
setStartTimer(it)
}
},
)
SwitchPreference(
icon = Icons.Rounded.Event,
title = stringResource(R.string.search_action_event),
value = settings.scheduleEvent,
onValueChanged = {
viewModel.updateSettings {
setScheduleEvent(it)
}
},
)
SwitchPreference(
icon = Icons.Rounded.Language,
title = stringResource(R.string.search_action_open_url),
value = settings.openUrl,
onValueChanged = {
viewModel.updateSettings {
setOpenUrl(it)
}
},
viewModel.addAction(item)
}
)
}
}

View File

@ -2,27 +2,39 @@ package de.mm20.launcher2.ui.settings.searchactions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import de.mm20.launcher2.searchactions.SearchActionService
import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class SearchActionsSettingsScreenVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val searchActionService: SearchActionService by inject()
val searchActionSettings = dataStore.data.map { it.searchActions }.asLiveData()
val searchActions = searchActionService
.getSearchActionBuilders()
.asLiveData()
fun updateSettings(block: SearchActionSettings.Builder.() -> SearchActionSettings.Builder) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setSearchActions(
it.searchActions.toBuilder().block()
).build()
}
}
val disabledActions = searchActionService
.getDisabledActionBuilders()
.asLiveData()
fun addAction(searchAction: SearchActionBuilder) {
val actions =
searchActions.value?.filter { it.key != searchAction.key }?.plus(searchAction) ?: return
searchActionService.saveSearchActionBuilders(actions)
}
fun removeAction(searchAction: SearchActionBuilder) {
val actions = searchActions.value?.filter { it.key != searchAction.key } ?: return
searchActionService.saveSearchActionBuilders(actions)
}
fun moveItem(fromIndex: Int, toIndex: Int) {
val actions = searchActions.value?.toMutableList() ?: return
if (fromIndex > actions.lastIndex) return
if (toIndex > actions.lastIndex) return
val item = actions.removeAt(fromIndex)
actions.add(toIndex, item)
searchActionService.saveSearchActionBuilders(actions)
}
}