From f862a578a1d79f9bb9f8ae952c6e2f5aae947c77 Mon Sep 17 00:00:00 2001
From: MM20 <15646950+MM2-0@users.noreply.github.com>
Date: Thu, 3 Nov 2022 20:01:24 +0100
Subject: [PATCH] Add search quick actions
---
app/build.gradle.kts | 1 +
.../de/mm20/launcher2/LauncherApplication.kt | 2 +
build.gradle.kts | 1 +
.../database/entities/SearchActionEntity.kt | 8 +
i18n/src/main/res/values/strings.xml | 11 +
.../mm20/launcher2/preferences/DataStore.kt | 3 +-
.../de/mm20/launcher2/preferences/Defaults.kt | 11 +
.../preferences/migrations/Migration_11_12.kt | 20 +
preferences/src/main/proto/settings.proto | 12 +
search-actions/.gitignore | 1 +
search-actions/build.gradle.kts | 48 +++
search-actions/consumer-rules.pro | 0
search-actions/proguard-rules.pro | 21 +
search-actions/src/main/AndroidManifest.xml | 4 +
.../de/mm20/launcher2/searchactions/Module.kt | 9 +
.../searchactions/SearchActionRepository.kt | 14 +
.../searchactions/SearchActionService.kt | 53 +++
.../launcher2/searchactions/TextClassifier.kt | 156 +++++++
.../searchactions/actions/CallAction.kt | 23 +
.../actions/CreateContactAction.kt | 25 ++
.../searchactions/actions/EmailAction.kt | 22 +
.../searchactions/actions/MessageAction.kt | 21 +
.../searchactions/actions/OpenUrlAction.kt | 24 ++
.../actions/ScheduleEventAction.kt | 34 ++
.../searchactions/actions/SearchAction.kt | 25 ++
.../searchactions/actions/SetAlarmAction.kt | 23 +
.../searchactions/actions/TimerAction.kt | 23 +
.../builders/CallActionBuilder.kt | 20 +
.../builders/CreateContactActionBuilder.kt | 22 +
.../builders/EmailActionBuilder.kt | 20 +
.../builders/MessageActionBuilder.kt | 19 +
.../builders/OpenUrlActionBuilder.kt | 20 +
.../builders/ScheduleEventActionBuilder.kt | 31 ++
.../builders/SearchActionBuilder.kt | 9 +
.../builders/SetAlarmActionBuilder.kt | 21 +
.../builders/TimerActionBuilder.kt | 20 +
.../builders/WebsearchActionBuilder.kt | 61 +++
search/build.gradle.kts | 1 +
.../java/de/mm20/launcher2/search/Module.kt | 1 +
.../de/mm20/launcher2/search/SearchService.kt | 17 +-
settings.gradle.kts | 1 +
ui/build.gradle.kts | 1 +
.../ui/assistant/AssistantScaffold.kt | 25 +-
.../launcher2/ui/component/LauncherCard.kt | 2 +-
.../mm20/launcher2/ui/component/SearchBar.kt | 205 +++++++++
.../ui/launcher/LauncherScaffoldVM.kt | 2 +
.../launcher2/ui/launcher/PagerScaffold.kt | 84 +++-
.../launcher2/ui/launcher/PullDownScaffold.kt | 27 +-
.../launcher2/ui/launcher/search/SearchBar.kt | 403 ------------------
.../launcher2/ui/launcher/search/SearchVM.kt | 33 +-
.../launcher/searchbar/LauncherSearchBar.kt | 53 +++
.../ui/launcher/searchbar/SearchBarActions.kt | 100 +++++
.../ui/launcher/searchbar/SearchBarMenu.kt | 107 +++++
.../launcher2/ui/settings/SettingsActivity.kt | 17 +-
.../appearance/AppearanceSettingsScreen.kt | 5 +-
.../settings/search/SearchSettingsScreen.kt | 15 +-
.../SearchActionsSettingsScreen.kt | 117 +++++
.../SearchActionsSettingsScreenVM.kt | 28 ++
58 files changed, 1617 insertions(+), 465 deletions(-)
create mode 100644 database/src/main/java/de/mm20/launcher2/database/entities/SearchActionEntity.kt
create mode 100644 preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration_11_12.kt
create mode 100644 search-actions/.gitignore
create mode 100644 search-actions/build.gradle.kts
create mode 100644 search-actions/consumer-rules.pro
create mode 100644 search-actions/proguard-rules.pro
create mode 100644 search-actions/src/main/AndroidManifest.xml
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/Module.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/TextClassifier.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CallAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CreateContactAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/EmailAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/MessageAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/OpenUrlAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/ScheduleEventAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SetAlarmAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/TimerAction.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CallActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CreateContactActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/EmailActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/MessageActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/OpenUrlActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/ScheduleEventActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SetAlarmActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/TimerActionBuilder.kt
create mode 100644 search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt
delete mode 100644 ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt
create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 66e0ab56..74e0ff59 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -135,6 +135,7 @@ dependencies {
implementation(project(":widgets"))
implementation(project(":wikipedia"))
implementation(project(":database"))
+ implementation(project(":search-actions"))
// Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is
//debugImplementation(libs.leakcanary)
diff --git a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt
index 5229eec5..34daf766 100644
--- a/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt
+++ b/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt
@@ -27,6 +27,7 @@ import de.mm20.launcher2.database.databaseModule
import de.mm20.launcher2.notifications.notificationsModule
import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.preferences.preferencesModule
+import de.mm20.launcher2.searchactions.searchActionsModule
import de.mm20.launcher2.weather.weatherModule
import kotlinx.coroutines.*
import org.koin.android.ext.koin.androidContext
@@ -68,6 +69,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
permissionsModule,
preferencesModule,
searchModule,
+ searchActionsModule,
unitConverterModule,
weatherModule,
websitesModule,
diff --git a/build.gradle.kts b/build.gradle.kts
index 47d4a8d2..4286ea44 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,6 +8,7 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle:7.3.1")
classpath(libs.kotlin.gradle)
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
diff --git a/database/src/main/java/de/mm20/launcher2/database/entities/SearchActionEntity.kt b/database/src/main/java/de/mm20/launcher2/database/entities/SearchActionEntity.kt
new file mode 100644
index 00000000..3de51873
--- /dev/null
+++ b/database/src/main/java/de/mm20/launcher2/database/entities/SearchActionEntity.kt
@@ -0,0 +1,8 @@
+package de.mm20.launcher2.database.entities
+
+data class SearchActionEntity(
+ val type: String,
+ val data: String? = null,
+ val options: String? = null,
+ val position: Int,
+)
\ No newline at end of file
diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml
index b9d873f4..1aa3a66e 100644
--- a/i18n/src/main/res/values/strings.xml
+++ b/i18n/src/main/res/values/strings.xml
@@ -676,4 +676,15 @@
Show in favorites
Number of rows
Tagsā¦
+
+ Quick actions
+ Configure quick actions and search shortcuts
+ Call
+ Message
+ Email
+ Set alarm
+ Start timer
+ Add to contacts
+ View website
+ Schedule event
\ No newline at end of file
diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt
index b156f5d9..8d80b9f3 100644
--- a/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt
+++ b/preferences/src/main/java/de/mm20/launcher2/preferences/DataStore.kt
@@ -22,7 +22,7 @@ internal val Context.dataStore: LauncherDataStore by dataStore(
}
)
-internal const val SchemaVersion = 11
+internal const val SchemaVersion = 12
internal fun getMigrations(context: Context): List> {
return listOf(
@@ -37,5 +37,6 @@ internal fun getMigrations(context: Context): List> {
Migration_8_9(),
Migration_9_10(),
Migration_10_11(),
+ Migration_11_12(),
)
}
\ No newline at end of file
diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt
index 1737a70e..1d0ac393 100644
--- a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt
+++ b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt
@@ -162,6 +162,17 @@ 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()
}
diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration_11_12.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration_11_12.kt
new file mode 100644
index 00000000..f15c012e
--- /dev/null
+++ b/preferences/src/main/java/de/mm20/launcher2/preferences/migrations/Migration_11_12.kt
@@ -0,0 +1,20 @@
+package de.mm20.launcher2.preferences.migrations
+
+import de.mm20.launcher2.preferences.Settings
+
+class Migration_11_12: VersionedMigration(11, 12) {
+ override suspend fun applyMigrations(builder: Settings.Builder): Settings.Builder {
+ return builder
+ .setSearchActions(
+ Settings.SearchActionSettings.newBuilder()
+ .setCall(true)
+ .setContact(true)
+ .setEmail(true)
+ .setMessage(true)
+ .setOpenUrl(true)
+ .setScheduleEvent(true)
+ .setSetAlarm(true)
+ .setStartTimer(true)
+ )
+ }
+}
\ No newline at end of file
diff --git a/preferences/src/main/proto/settings.proto b/preferences/src/main/proto/settings.proto
index 12100f01..edd65357 100644
--- a/preferences/src/main/proto/settings.proto
+++ b/preferences/src/main/proto/settings.proto
@@ -288,4 +288,16 @@ 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;
}
\ No newline at end of file
diff --git a/search-actions/.gitignore b/search-actions/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/search-actions/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/search-actions/build.gradle.kts b/search-actions/build.gradle.kts
new file mode 100644
index 00000000..f17a0cd2
--- /dev/null
+++ b/search-actions/build.gradle.kts
@@ -0,0 +1,48 @@
+plugins {
+ id("com.android.library")
+ id("kotlin-android")
+}
+
+android {
+ compileSdk = sdk.versions.compileSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = sdk.versions.minSdk.get().toInt()
+ targetSdk = sdk.versions.targetSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ namespace = "de.mm20.launcher2.searchactions"
+}
+
+dependencies {
+ implementation(libs.bundles.kotlin)
+ implementation(libs.androidx.core)
+
+ implementation(libs.koin.android)
+
+ implementation(project(":base"))
+ implementation(project(":database"))
+ implementation(project(":ktx"))
+ implementation(project(":preferences"))
+
+}
\ No newline at end of file
diff --git a/search-actions/consumer-rules.pro b/search-actions/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/search-actions/proguard-rules.pro b/search-actions/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/search-actions/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/search-actions/src/main/AndroidManifest.xml b/search-actions/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/search-actions/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/Module.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/Module.kt
new file mode 100644
index 00000000..a6d1245b
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/Module.kt
@@ -0,0 +1,9 @@
+package de.mm20.launcher2.searchactions
+
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+val searchActionsModule = module {
+ single { SearchActionRepositoryImpl() }
+ single { SearchActionServiceImpl(androidContext(), get(), TextClassifierImpl()) }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt
new file mode 100644
index 00000000..f2ca6bbd
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionRepository.kt
@@ -0,0 +1,14 @@
+package de.mm20.launcher2.searchactions
+
+import de.mm20.launcher2.searchactions.builders.SearchActionBuilder
+import kotlinx.coroutines.flow.Flow
+
+interface SearchActionRepository {
+ fun getSearchActionBuilders(filter: TextType?): Flow>
+}
+
+internal class SearchActionRepositoryImpl: SearchActionRepository {
+ override fun getSearchActionBuilders(filter: TextType?): Flow> {
+ TODO("Not yet implemented")
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt
new file mode 100644
index 00000000..42d90a4e
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/SearchActionService.kt
@@ -0,0 +1,53 @@
+package de.mm20.launcher2.searchactions
+
+import android.content.Context
+import de.mm20.launcher2.preferences.Settings.SearchActionSettings
+import de.mm20.launcher2.searchactions.actions.SearchAction
+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.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+interface SearchActionService {
+ fun search(settings: SearchActionSettings, query: String): Flow>
+}
+
+internal class SearchActionServiceImpl(
+ private val context: Context,
+ private val repository: SearchActionRepository,
+ private val textClassifier: TextClassifier,
+) : SearchActionService {
+ override fun search(settings: SearchActionSettings, query: String): Flow> = flow {
+ if (query.isBlank()) {
+ emit(persistentListOf())
+ return@flow
+ }
+
+ val builders = mutableListOf()
+
+ 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)
+
+
+ emit(builders.mapNotNull { it.build(context, classificationResult) }.toImmutableList())
+ }
+
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/TextClassifier.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/TextClassifier.kt
new file mode 100644
index 00000000..a8cf4f84
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/TextClassifier.kt
@@ -0,0 +1,156 @@
+package de.mm20.launcher2.searchactions
+
+import android.content.Context
+import android.icu.text.SimpleDateFormat
+import android.text.format.DateFormat
+import java.text.ParseException
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneId
+import java.util.Locale
+
+internal interface TextClassifier {
+ suspend fun classify(context: Context, query: String): TextClassificationResult
+}
+
+internal class TextClassifierImpl : TextClassifier {
+ override suspend fun classify(context: Context, query: String): TextClassificationResult {
+ return when {
+ query.matches(Regex("^\\S+@\\S+$")) -> TextClassificationResult(
+ type = TextType.Email,
+ text = query,
+ email = query
+ )
+
+ query.matches(Regex("^\\+?[0-9- ]{4,18}$")) -> TextClassificationResult(
+ type = TextType.PhoneNumber,
+ text = query,
+ phoneNumber = query
+ )
+
+ query.matches(Regex("^(http(s)?://.)?(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)$")) -> TextClassificationResult(
+ type = TextType.Url,
+ text = query,
+ url = query
+ )
+
+ else -> {
+ parseDate(context, query)?.let { return it }
+ TextClassificationResult(type = TextType.Text, text = query)
+ }
+ }
+ }
+
+ private fun parseDate(context: Context, query: String): TextClassificationResult? {
+ val dateTimeFormat = SimpleDateFormat(
+ DateFormat.getBestDateTimePattern(
+ Locale.getDefault(),
+ "yyyy-MM-dd, HH:mm"
+ )
+ )
+ try {
+ dateTimeFormat.parse(query)?.let {
+ val dateTime = LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
+ return TextClassificationResult(
+ type = TextType.DateTime,
+ text = query,
+ time = dateTime.toLocalTime(),
+ date = dateTime.toLocalDate(),
+ )
+ }
+ } catch (_: ParseException) {
+ // Not a datetime
+ }
+ val dateFormat = DateFormat.getDateFormat(context)
+ try {
+ dateFormat.parse(query)?.let {
+ return TextClassificationResult(
+ type = TextType.Date,
+ text = query,
+ date = LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
+ .toLocalDate()
+ )
+ }
+ } catch (_: ParseException) {
+ // Not a date either
+ }
+ val timeFormat = DateFormat.getTimeFormat(context)
+ try {
+ timeFormat.parse(query)?.let {
+ return TextClassificationResult(
+ type = TextType.Time,
+ text = query,
+ time = LocalDateTime.ofInstant(it.toInstant(), ZoneId.systemDefault())
+ .toLocalTime(),
+ )
+ }
+ } catch (_: ParseException) {
+ // Nope, not a time
+ }
+
+ val seconds = context.getString(R.string.unit_second_symbol)
+ if (query.matches(Regex("^[0-9]+ ${seconds}$"))) {
+ val value = query.substringBefore(" ").toLong()
+ return TextClassificationResult(
+ type = TextType.Timespan,
+ text = query,
+ timespan = Duration.ofSeconds(value)
+ )
+ }
+
+ val days = context.getString(R.string.unit_day_symbol)
+ if (query.matches(Regex("^[0-9]+ ${days}$"))) {
+ val value = query.substringBefore(" ").toLong()
+ return TextClassificationResult(
+ type = TextType.Timespan,
+ text = query,
+ timespan = Duration.ofDays(value)
+ )
+ }
+ val minutes = context.getString(R.string.unit_minute_symbol)
+ if (query.matches(Regex("^[0-9]+ ${minutes}$"))) {
+ val value = query.substringBefore(" ").toLong()
+ val then = LocalDateTime.now().plusMinutes(value)
+ return TextClassificationResult(
+ type = TextType.Timespan,
+ text = query,
+ timespan = Duration.ofMinutes(value)
+ )
+ }
+ val hours = context.getString(R.string.unit_hour_symbol)
+ if (query.matches(Regex("^[0-9]+ ${hours}$"))) {
+ val value = query.substringBefore(" ").toLong()
+ return TextClassificationResult(
+ type = TextType.Timespan,
+ text = query,
+ timespan = Duration.ofHours(value)
+ )
+ }
+
+ return null
+ }
+}
+
+data class TextClassificationResult(
+ val type: TextType,
+ val text: String,
+ val email: String? = null,
+ val phoneNumber: String? = null,
+ val time: LocalTime? = null,
+ val date: LocalDate? = null,
+ val timespan: Duration? = null,
+ val url: String? = null,
+)
+
+enum class TextType {
+ Text,
+ Email,
+ Url,
+ PhoneNumber,
+ DateTime,
+ Date,
+ Time,
+ Timespan,
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CallAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CallAction.kt
new file mode 100644
index 00000000..bc46770d
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CallAction.kt
@@ -0,0 +1,23 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import de.mm20.launcher2.ktx.tryStartActivity
+import de.mm20.launcher2.searchactions.R
+
+data class CallAction(
+ override val label: String,
+ val number: String,
+): SearchAction {
+
+ override val icon: SearchActionIcon = SearchActionIcon.Phone
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val intent = Intent(Intent.ACTION_DIAL).apply {
+ data = Uri.parse("tel:$number")
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CreateContactAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CreateContactAction.kt
new file mode 100644
index 00000000..d9480372
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/CreateContactAction.kt
@@ -0,0 +1,25 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.provider.ContactsContract
+import android.provider.ContactsContract.Intents.Insert
+import de.mm20.launcher2.ktx.tryStartActivity
+
+class CreateContactAction(
+ override val label: String,
+ val phone: String? = null,
+ val email: String? = null,
+) : SearchAction {
+ override val icon: SearchActionIcon = SearchActionIcon.Contact
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val intent = Intent(Intent.ACTION_INSERT).apply {
+ type = ContactsContract.Contacts.CONTENT_TYPE
+ if (email != null) putExtra(Insert.EMAIL, email)
+ if (phone != null) putExtra(Insert.PHONE, phone)
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/EmailAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/EmailAction.kt
new file mode 100644
index 00000000..b6cafba7
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/EmailAction.kt
@@ -0,0 +1,22 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import de.mm20.launcher2.ktx.tryStartActivity
+
+data class EmailAction(
+ override val label: String,
+ val email: String,
+) : SearchAction {
+ override val icon: SearchActionIcon = SearchActionIcon.Email
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ type = "*/*"
+ data = Uri.parse("mailto:$email")
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/MessageAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/MessageAction.kt
new file mode 100644
index 00000000..87f7fffe
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/MessageAction.kt
@@ -0,0 +1,21 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import de.mm20.launcher2.ktx.tryStartActivity
+
+data class MessageAction(
+ override val label: String,
+ val number: String,
+): SearchAction {
+ override val icon: SearchActionIcon = SearchActionIcon.Message
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("sms:$number")
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/OpenUrlAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/OpenUrlAction.kt
new file mode 100644
index 00000000..d059c0ef
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/OpenUrlAction.kt
@@ -0,0 +1,24 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import de.mm20.launcher2.ktx.tryStartActivity
+
+data class OpenUrlAction(
+ override val label: String,
+ val url: String,
+) : SearchAction {
+
+ override val icon: SearchActionIcon = SearchActionIcon.Website
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val url = if (url.startsWith("https://") || url.startsWith("http://")) url else "https://$url"
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse(url)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/ScheduleEventAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/ScheduleEventAction.kt
new file mode 100644
index 00000000..699e038e
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/ScheduleEventAction.kt
@@ -0,0 +1,34 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.provider.CalendarContract
+import de.mm20.launcher2.ktx.tryStartActivity
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneId
+
+data class ScheduleEventAction(
+ override val label: String,
+ val date: LocalDate,
+ val time: LocalTime?,
+) : SearchAction {
+ override val icon: SearchActionIcon = SearchActionIcon.Calendar
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+
+ val startTime = date.let {
+ if (time != null) it.atTime(time)
+ else it.atTime(0, 0)
+ }.atZone(ZoneId.systemDefault()).toEpochSecond() * 1000L
+
+ val intent = Intent(Intent.ACTION_INSERT).apply {
+ type = "vnd.android.cursor.dir/event"
+ putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startTime)
+ if (time == null) putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true)
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt
new file mode 100644
index 00000000..145a6c88
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SearchAction.kt
@@ -0,0 +1,25 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import de.mm20.launcher2.search.Searchable
+
+interface SearchAction : Searchable {
+ val label: String
+ val icon: SearchActionIcon
+ val iconColor: Int
+ fun start(context: Context)
+}
+
+enum class SearchActionIcon {
+ Search,
+ Website,
+ Alarm,
+ Timer,
+ Contact,
+ Phone,
+ Email,
+ Message,
+ Calendar,
+ Translate,
+ Custom,
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SetAlarmAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SetAlarmAction.kt
new file mode 100644
index 00000000..b1f9760f
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/SetAlarmAction.kt
@@ -0,0 +1,23 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.provider.AlarmClock
+import de.mm20.launcher2.ktx.tryStartActivity
+import java.time.LocalTime
+
+data class SetAlarmAction(
+ override val label: String,
+ val time: LocalTime
+) : SearchAction {
+ override val icon: SearchActionIcon = SearchActionIcon.Alarm
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply {
+ putExtra(AlarmClock.EXTRA_HOUR, time.hour)
+ putExtra(AlarmClock.EXTRA_MINUTES, time.minute)
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/TimerAction.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/TimerAction.kt
new file mode 100644
index 00000000..1346b41d
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/actions/TimerAction.kt
@@ -0,0 +1,23 @@
+package de.mm20.launcher2.searchactions.actions
+
+import android.content.Context
+import android.content.Intent
+import android.provider.AlarmClock
+import de.mm20.launcher2.ktx.tryStartActivity
+import java.time.Duration
+
+data class TimerAction(
+ override val label: String,
+ val length: Duration
+): SearchAction {
+
+ override val icon: SearchActionIcon = SearchActionIcon.Timer
+ override val iconColor: Int = 0
+
+ override fun start(context: Context) {
+ val intent = Intent(AlarmClock.ACTION_SET_TIMER).apply {
+ putExtra(AlarmClock.EXTRA_LENGTH, length.seconds.toInt())
+ }
+ context.tryStartActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CallActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CallActionBuilder.kt
new file mode 100644
index 00000000..ff8de3e3
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CallActionBuilder.kt
@@ -0,0 +1,20 @@
+package de.mm20.launcher2.searchactions.builders
+
+import android.content.Context
+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
+
+object CallActionBuilder: SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.phoneNumber != null) {
+ return CallAction(
+ context.getString(R.string.search_action_call), classifiedQuery.phoneNumber
+ )
+ }
+ return null
+ }
+
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CreateContactActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CreateContactActionBuilder.kt
new file mode 100644
index 00000000..4167cc9b
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/CreateContactActionBuilder.kt
@@ -0,0 +1,22 @@
+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.CreateContactAction
+import de.mm20.launcher2.searchactions.actions.SearchAction
+
+object CreateContactActionBuilder : SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.phoneNumber != null || classifiedQuery.email != null) {
+ return CreateContactAction(
+ context.getString(R.string.search_action_contact),
+ phone = classifiedQuery.phoneNumber,
+ email = classifiedQuery.email,
+ )
+ }
+ return null
+ }
+
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/EmailActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/EmailActionBuilder.kt
new file mode 100644
index 00000000..92caa936
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/EmailActionBuilder.kt
@@ -0,0 +1,20 @@
+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.EmailAction
+import de.mm20.launcher2.searchactions.actions.SearchAction
+
+object EmailActionBuilder: SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.email != null) {
+ return EmailAction(
+ context.getString(R.string.search_action_email), classifiedQuery.email
+ )
+ }
+ return null
+ }
+
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/MessageActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/MessageActionBuilder.kt
new file mode 100644
index 00000000..1287d422
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/MessageActionBuilder.kt
@@ -0,0 +1,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.MessageAction
+import de.mm20.launcher2.searchactions.actions.SearchAction
+
+object MessageActionBuilder: SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.phoneNumber != null) {
+ return MessageAction(
+ context.getString(R.string.search_action_message), classifiedQuery.phoneNumber
+ )
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/OpenUrlActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/OpenUrlActionBuilder.kt
new file mode 100644
index 00000000..6d38fe83
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/OpenUrlActionBuilder.kt
@@ -0,0 +1,20 @@
+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.MessageAction
+import de.mm20.launcher2.searchactions.actions.OpenUrlAction
+import de.mm20.launcher2.searchactions.actions.SearchAction
+
+object OpenUrlActionBuilder : SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.url != null) {
+ return OpenUrlAction(
+ context.getString(R.string.search_action_open_url), classifiedQuery.url
+ )
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/ScheduleEventActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/ScheduleEventActionBuilder.kt
new file mode 100644
index 00000000..68269186
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/ScheduleEventActionBuilder.kt
@@ -0,0 +1,31 @@
+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.MessageAction
+import de.mm20.launcher2.searchactions.actions.ScheduleEventAction
+import de.mm20.launcher2.searchactions.actions.SearchAction
+import java.time.LocalDateTime
+
+object ScheduleEventActionBuilder : SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.date != null) {
+ return ScheduleEventAction(
+ context.getString(R.string.search_action_event),
+ date = classifiedQuery.date,
+ time = classifiedQuery.time
+ )
+ }
+ if (classifiedQuery.timespan != null && classifiedQuery.timespan.seconds > 86400) {
+ val datetime = LocalDateTime.now().plus(classifiedQuery.timespan)
+ return ScheduleEventAction(
+ context.getString(R.string.search_action_event),
+ date = datetime.toLocalDate(),
+ time = datetime.toLocalTime(),
+ )
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt
new file mode 100644
index 00000000..bfc9c3b9
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SearchActionBuilder.kt
@@ -0,0 +1,9 @@
+package de.mm20.launcher2.searchactions.builders
+
+import android.content.Context
+import de.mm20.launcher2.searchactions.actions.SearchAction
+import de.mm20.launcher2.searchactions.TextClassificationResult
+
+interface SearchActionBuilder {
+ fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction?
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SetAlarmActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SetAlarmActionBuilder.kt
new file mode 100644
index 00000000..d1553cad
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/SetAlarmActionBuilder.kt
@@ -0,0 +1,21 @@
+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.SearchAction
+import de.mm20.launcher2.searchactions.actions.SetAlarmAction
+import java.time.LocalDate
+
+object SetAlarmActionBuilder : SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.time != null) {
+ return SetAlarmAction(
+ context.getString(R.string.search_action_alarm), classifiedQuery.time
+ )
+ }
+ return null
+ }
+
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/TimerActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/TimerActionBuilder.kt
new file mode 100644
index 00000000..fa87d5a2
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/TimerActionBuilder.kt
@@ -0,0 +1,20 @@
+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
+
+object TimerActionBuilder : SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (classifiedQuery.timespan != null && classifiedQuery.timespan.seconds <= 86400) {
+ return TimerAction(
+ context.getString(R.string.search_action_timer), classifiedQuery.timespan
+ )
+ }
+ return null
+ }
+
+}
\ No newline at end of file
diff --git a/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt
new file mode 100644
index 00000000..fc7d16ad
--- /dev/null
+++ b/search-actions/src/main/java/de/mm20/launcher2/searchactions/builders/WebsearchActionBuilder.kt
@@ -0,0 +1,61 @@
+package de.mm20.launcher2.searchactions.builders
+
+import android.content.Context
+import android.net.Uri
+import de.mm20.launcher2.searchactions.actions.SearchAction
+import de.mm20.launcher2.searchactions.TextClassificationResult
+import de.mm20.launcher2.searchactions.TextType
+import de.mm20.launcher2.searchactions.actions.OpenUrlAction
+import java.net.URLEncoder
+
+class WebsearchActionBuilder(
+ val label: String,
+ val urlTemplate: String,
+ val filter: TextType? = null,
+ val encoding: QueryEncoding,
+) : SearchActionBuilder {
+
+ override fun build(context: Context, classifiedQuery: TextClassificationResult): SearchAction? {
+ if (filter == null || classifiedQuery.type == filter) {
+ val url = urlTemplate.replace("\${1}", encodeQuery(classifiedQuery.text, encoding))
+ return OpenUrlAction(
+ label = label,
+ url = url,
+ )
+ }
+ return null
+ }
+
+
+ private fun encodeQuery(query: String, encoding: QueryEncoding): String {
+ return when (encoding) {
+ QueryEncoding.UrlEncode -> Uri.encode(query)
+ QueryEncoding.FormData -> URLEncoder.encode(query, "UTF-8")
+ QueryEncoding.None -> query
+ }
+ }
+
+ enum class QueryEncoding {
+ UrlEncode,
+ FormData,
+ None;
+
+ fun toInt(): Int {
+ return when (this) {
+ UrlEncode -> 0
+ FormData -> 1
+ None -> 2
+ }
+ }
+
+ companion object {
+ fun fromInt(value: Int?): QueryEncoding {
+ return when (value) {
+ 1 -> FormData
+ 2 -> None
+ else -> UrlEncode
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/search/build.gradle.kts b/search/build.gradle.kts
index 03b49b23..48d09de6 100644
--- a/search/build.gradle.kts
+++ b/search/build.gradle.kts
@@ -57,6 +57,7 @@ dependencies {
implementation(project(":websites"))
implementation(project(":wikipedia"))
implementation(project(":customattrs"))
+ implementation(project(":search-actions"))
implementation(project(":base"))
implementation(project(":database"))
diff --git a/search/src/main/java/de/mm20/launcher2/search/Module.kt b/search/src/main/java/de/mm20/launcher2/search/Module.kt
index 35cf5334..0c4c5554 100644
--- a/search/src/main/java/de/mm20/launcher2/search/Module.kt
+++ b/search/src/main/java/de/mm20/launcher2/search/Module.kt
@@ -16,6 +16,7 @@ val searchModule = module {
get(),
get(),
get(),
+ get(),
)
}
single { WebsearchRepositoryImpl(androidContext(), get()) }
diff --git a/search/src/main/java/de/mm20/launcher2/search/SearchService.kt b/search/src/main/java/de/mm20/launcher2/search/SearchService.kt
index 43577b8d..c9902f1d 100644
--- a/search/src/main/java/de/mm20/launcher2/search/SearchService.kt
+++ b/search/src/main/java/de/mm20/launcher2/search/SearchService.kt
@@ -13,6 +13,7 @@ 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
@@ -30,6 +31,8 @@ import de.mm20.launcher2.search.data.OwncloudFile
import de.mm20.launcher2.search.data.UnitConverter
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
+import de.mm20.launcher2.searchactions.actions.SearchAction
+import de.mm20.launcher2.searchactions.SearchActionService
import de.mm20.launcher2.unitconverter.UnitConverterRepository
import de.mm20.launcher2.websites.WebsiteRepository
import de.mm20.launcher2.wikipedia.WikipediaRepository
@@ -56,6 +59,7 @@ interface SearchService {
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
+ searchActions: SearchActionSettings,
): Flow>
}
@@ -69,6 +73,7 @@ internal class SearchServiceImpl(
private val unitConverterRepository: UnitConverterRepository,
private val calculatorRepository: CalculatorRepository,
private val websiteRepository: WebsiteRepository,
+ private val searchActionService: SearchActionService,
private val customAttributesRepository: CustomAttributesRepository,
) : SearchService {
@@ -82,6 +87,7 @@ internal class SearchServiceImpl(
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
+ searchActions: SearchActionSettings,
): Flow> = channelFlow {
supervisorScope {
val results = MutableStateFlow(SearchResults())
@@ -213,6 +219,14 @@ internal class SearchServiceImpl(
}
}
}
+ launch {
+ searchActionService.search(searchActions, query)
+ .collectLatest { r ->
+ results.update {
+ it.copy(searchActions = r)
+ }
+ }
+ }
launch {
results
.map { it.toList().sortedBy { it as? SavableSearchable }.toImmutableList() }
@@ -234,9 +248,10 @@ internal data class SearchResults(
val unitConverters: List = emptyList(),
val websites: List = emptyList(),
val wikipedia: List = emptyList(),
+ val searchActions: List = emptyList(),
val other: List = emptyList(),
) {
fun toList(): List {
- return (apps + shortcuts + contacts + calendars + files + websites + wikipedia + other).distinctBy { it.key } + calculators + unitConverters
+ return searchActions + (apps + shortcuts + contacts + calendars + files + websites + wikipedia + other).distinctBy { it.key } + calculators + unitConverters
}
}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e79127aa..634eccc0 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -287,3 +287,4 @@ dependencyResolutionManagement {
}
}
}
+include(":search-actions")
diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts
index 3c690199..6ffa6cab 100644
--- a/ui/build.gradle.kts
+++ b/ui/build.gradle.kts
@@ -141,4 +141,5 @@ dependencies {
implementation(project(":owncloud"))
implementation(project(":accounts"))
implementation(project(":backup"))
+ implementation(project(":search-actions"))
}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt b/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt
index 9098de53..f1a7079d 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt
@@ -8,7 +8,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -22,12 +21,13 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings
+import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.launcher.LauncherScaffoldVM
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
-import de.mm20.launcher2.ui.launcher.search.SearchBar
-import de.mm20.launcher2.ui.launcher.search.SearchBarLevel
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
+import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
+import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -162,9 +162,9 @@ fun AssistantScaffold(
}
val searchVM: SearchVM = viewModel()
- val websearches by searchVM.websearchResults.observeAsState(emptyList())
+ val actions by searchVM.searchActionResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState(
- if (websearches.isEmpty()) 0.dp else 48.dp
+ if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
Box(
@@ -182,8 +182,12 @@ fun AssistantScaffold(
state = searchState
)
- SearchBar(
- level = { searchBarLevel },
+ val value by searchVM.searchQuery.observeAsState("")
+
+ val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
+ val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
+
+ LauncherSearchBar(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
@@ -197,10 +201,17 @@ fun AssistantScaffold(
searchBarOffset.toInt() * if (bottomSearchBar == true) -1 else 1
)
},
+ level = { searchBarLevel },
focused = searchBarFocused,
onFocusChange = {
+ if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
},
+ actions = actions,
+ value = { value },
+ onValueChange = { searchVM.search(it) },
+ darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == Settings.SearchBarSettings.SearchBarColors.Auto || searchBarColor == Settings.SearchBarSettings.SearchBarColors.Dark,
+ style = searchBarStyle,
reverse = bottomSearchBar == true
)
}
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt
index ed70ab37..fe9d8057 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt
@@ -42,7 +42,7 @@ fun LauncherCard(
contentColor = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.surface.copy(alpha = backgroundOpacity.coerceIn(0f, 1f)),
shadowElevation = if (backgroundOpacity == 1f) elevation else 0.dp,
- tonalElevation = elevation
+ tonalElevation = elevation,
)
}
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt
new file mode 100644
index 00000000..4af199b6
--- /dev/null
+++ b/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt
@@ -0,0 +1,205 @@
+package de.mm20.launcher2.ui.component
+
+import androidx.compose.animation.animateColor
+import androidx.compose.animation.core.animateDp
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.mm20.launcher2.preferences.Settings
+import de.mm20.launcher2.ui.R
+import de.mm20.launcher2.ui.layout.BottomReversed
+import de.mm20.launcher2.ui.locals.LocalCardStyle
+
+@Composable
+fun SearchBar(
+ modifier: Modifier = Modifier,
+ style: Settings.SearchBarSettings.SearchBarStyle,
+ level: SearchBarLevel,
+ value: String,
+ onValueChange: (String) -> Unit,
+ focusRequester: FocusRequester = remember { FocusRequester() },
+ onFocus: () -> Unit = {},
+ onUnfocus: () -> Unit = {},
+ reverse: Boolean = false,
+ darkColors: Boolean = false,
+ menu: @Composable RowScope.() -> Unit = {},
+ actions: @Composable () -> Unit = {},
+) {
+
+ val transition = updateTransition(level, label = "Searchbar")
+
+ val elevation by transition.animateDp(
+ label = "elevation",
+ transitionSpec = {
+ when {
+ initialState == SearchBarLevel.Resting -> tween(
+ durationMillis = 200,
+ delayMillis = 200
+ )
+
+ targetState == SearchBarLevel.Resting -> tween(durationMillis = 200)
+ else -> tween(durationMillis = 500)
+ }
+ }
+ ) {
+ when {
+ it == SearchBarLevel.Resting && style != Settings.SearchBarSettings.SearchBarStyle.Solid -> 0.dp
+ it == SearchBarLevel.Raised -> 8.dp
+ else -> 2.dp
+ }
+ }
+
+ val backgroundOpacity by transition.animateFloat(label = "backgroundOpacity",
+ transitionSpec = {
+ when {
+ initialState == SearchBarLevel.Resting -> tween(durationMillis = 200)
+ targetState == SearchBarLevel.Resting -> tween(
+ durationMillis = 200,
+ delayMillis = 200
+ )
+
+ else -> tween(durationMillis = 200)
+ }
+ }) {
+ when {
+ it == SearchBarLevel.Active -> LocalCardStyle.current.opacity
+ style != Settings.SearchBarSettings.SearchBarStyle.Transparent -> 1f
+ it == SearchBarLevel.Resting -> 0f
+ else -> 1f
+ }
+ }
+
+ val contentColor by transition.animateColor(label = "textColor",
+ transitionSpec = {
+ when {
+ initialState == SearchBarLevel.Resting -> tween(durationMillis = 200)
+ targetState == SearchBarLevel.Resting -> tween(
+ durationMillis = 200,
+ delayMillis = 200
+ )
+
+ else -> tween(durationMillis = 500)
+ }
+ }) {
+ when {
+ style != Settings.SearchBarSettings.SearchBarStyle.Transparent -> MaterialTheme.colorScheme.onSurface
+ it == SearchBarLevel.Resting -> if (darkColors) Color(0, 0, 0, 180) else Color.White
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ }
+
+ val opacity by transition.animateFloat(label = "opacity") {
+ if (style == Settings.SearchBarSettings.SearchBarStyle.Hidden && it == SearchBarLevel.Resting) 0f
+ else 1f
+ }
+
+ LauncherCard(
+ modifier = modifier
+ .alpha(opacity),
+ backgroundOpacity = backgroundOpacity,
+ elevation = elevation
+ ) {
+ CompositionLocalProvider(
+ LocalContentColor provides contentColor
+ ) {
+ Column(
+ verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top
+ ) {
+ Row(
+ modifier = Modifier.height(48.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ modifier = Modifier.padding(12.dp),
+ imageVector = androidx.compose.material.icons.Icons.Rounded.Search,
+ contentDescription = null,
+ tint = contentColor
+ )
+ Box(
+ modifier = Modifier.weight(1f)
+ ) {
+ if (value.isEmpty()) {
+ Text(
+ text = stringResource(R.string.search_bar_placeholder),
+ style = MaterialTheme.typography.titleMedium,
+ color = contentColor
+ )
+ }
+ LaunchedEffect(level) {
+ if (level == SearchBarLevel.Resting) onUnfocus()
+ }
+ BasicTextField(
+ modifier = Modifier
+ .onFocusChanged {
+ if (it.hasFocus) onFocus()
+ }
+ .focusRequester(focusRequester)
+ .fillMaxWidth(),
+ textStyle = MaterialTheme.typography.titleMedium.copy(
+ color = contentColor
+ ),
+ singleLine = true,
+ value = value,
+ onValueChange = onValueChange,
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary)
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ menu()
+ }
+ }
+ actions()
+ }
+ }
+ }
+}
+
+enum class SearchBarLevel {
+ /**
+ * The default, "hidden" state, when the launcher is in its initial state (scroll position is 0
+ * and search is closed)
+ */
+ Resting,
+
+ /**
+ * When the search is open but there is no content behind the search bar (scroll position is 0)
+ */
+ Active,
+
+ /**
+ * When there is content below the search bar which requires the search bar to be raised above
+ * this content (scroll position is not 0)
+ */
+ Raised
+}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt
index 51b4843d..84aa8404 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt
@@ -52,4 +52,6 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
val wallpaperBlur = dataStore.data.map { it.appearance.blurWallpaper }.asLiveData()
val fillClockHeight = dataStore.data.map { it.clockWidget.fillHeight }.asLiveData()
+ val searchBarColor = dataStore.data.map { it.searchBar.color }.asLiveData()
+ val searchBarStyle = dataStore.data.map { it.searchBar.searchBarStyle }.asLiveData()
}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt
index a80957d4..8af2ec04 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt
@@ -1,6 +1,5 @@
package de.mm20.launcher2.ui.launcher
-import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
@@ -8,7 +7,24 @@ import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -17,9 +33,19 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material.rememberSwipeableState
import androidx.compose.material.swipeable
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -37,15 +63,18 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarColors
+import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarStyle
import de.mm20.launcher2.ui.R
+import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
-import de.mm20.launcher2.ui.launcher.search.SearchBar
-import de.mm20.launcher2.ui.launcher.search.SearchBarLevel
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
+import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
+import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import de.mm20.launcher2.ui.utils.rememberNotificationShadeController
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
@@ -64,6 +93,8 @@ fun PagerScaffold(
val isSearchOpen by viewModel.isSearchOpen.observeAsState(false)
val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false)
+ val actions by searchVM.searchActionResults.observeAsState(emptyList())
+
val widgetsScrollState = rememberScrollState()
val searchState = rememberLazyListState()
val swipeableState = rememberSwipeableState(if (isSearchOpen) Page.Search else Page.Widgets)
@@ -76,7 +107,8 @@ fun PagerScaffold(
val isSearchAtEnd by remember {
derivedStateOf {
- val lastItem = searchState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
+ val lastItem =
+ searchState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true
lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding
}
}
@@ -179,9 +211,11 @@ fun PagerScaffold(
viewModel.closeSearch()
searchVM.search("")
}
+
isWidgetEditMode -> {
viewModel.setWidgetEditMode(false)
}
+
widgetsScrollState.value != 0 -> {
scope.launch {
widgetsScrollState.animateScrollTo(0)
@@ -226,14 +260,16 @@ fun PagerScaffold(
}
}
- val searchNestedScrollConnection = remember { object: NestedScrollConnection {
- override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
- if (source == NestedScrollSource.Drag && available.y.absoluteValue > available.x.absoluteValue * 2) {
- keyboardController?.hide()
+ val searchNestedScrollConnection = remember {
+ object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ if (source == NestedScrollSource.Drag && available.y.absoluteValue > available.x.absoluteValue * 2) {
+ keyboardController?.hide()
+ }
+ return super.onPreScroll(available, source)
}
- return super.onPreScroll(available, source)
}
- }}
+ }
val insets = WindowInsets.safeDrawing.asPaddingValues()
@@ -296,7 +332,7 @@ fun PagerScaffold(
val clockHeight by remember {
derivedStateOf {
- if (fillClockHeight){
+ if (fillClockHeight) {
height - (64.dp + insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding)
} else {
null
@@ -338,10 +374,8 @@ fun PagerScaffold(
)
}
-
- val websearches by searchVM.websearchResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState(
- if (websearches.isEmpty()) 0.dp else 48.dp
+ if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
SearchColumn(
@@ -398,17 +432,29 @@ fun PagerScaffold(
if (isWidgetEditMode) 128.dp else 0.dp
)
- SearchBar(
+ val value by searchVM.searchQuery.observeAsState("")
+
+ val searchBarColor by viewModel.searchBarColor.observeAsState(SearchBarColors.Auto)
+ val searchBarStyle by viewModel.searchBarStyle.observeAsState(SearchBarStyle.Transparent)
+
+ LauncherSearchBar(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
.windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding()
.offset(y = widgetEditModeOffset),
- level = { searchBarLevel }, focused = focusSearchBar, onFocusChange = {
+ level = { searchBarLevel },
+ focused = focusSearchBar,
+ onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
},
+ actions = actions,
+ value = { value },
+ onValueChange = { searchVM.search(it) },
+ darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
+ style = searchBarStyle,
reverse = true
)
}
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt
index 21f211f5..74bd18ba 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt
@@ -34,15 +34,17 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.R
+import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
-import de.mm20.launcher2.ui.launcher.search.SearchBar
-import de.mm20.launcher2.ui.launcher.search.SearchBarLevel
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
+import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
+import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@@ -58,6 +60,8 @@ fun PullDownScaffold(
val density = LocalDensity.current
+ val actions by searchVM.searchActionResults.observeAsState(emptyList())
+
val isSearchOpen by viewModel.isSearchOpen.observeAsState(false)
val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false)
@@ -277,9 +281,8 @@ fun PullDownScaffold(
)
}
) {
- val websearches by searchVM.websearchResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState(
- if (websearches.isEmpty()) 0.dp else 48.dp
+ if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
SearchColumn(
@@ -392,8 +395,12 @@ fun PullDownScaffold(
if (isWidgetEditMode) -128.dp else 0.dp
)
- SearchBar(
- level = { searchBarLevel },
+ val value by searchVM.searchQuery.observeAsState("")
+
+ val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
+ val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
+
+ LauncherSearchBar(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
@@ -410,11 +417,17 @@ fun PullDownScaffold(
.roundToInt()
})
},
+ level = { searchBarLevel },
focused = searchBarFocused,
onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
- }
+ },
+ actions = actions,
+ value = { value },
+ onValueChange = { searchVM.search(it) },
+ darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == Settings.SearchBarSettings.SearchBarColors.Auto || searchBarColor == Settings.SearchBarSettings.SearchBarColors.Dark,
+ style = searchBarStyle,
)
}
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt
deleted file mode 100644
index 029f05d7..00000000
--- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt
+++ /dev/null
@@ -1,403 +0,0 @@
-package de.mm20.launcher2.ui.launcher.search
-
-import android.content.Intent
-import android.net.Uri
-import androidx.browser.customtabs.CustomTabColorSchemeParams
-import androidx.browser.customtabs.CustomTabsIntent
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateColor
-import androidx.compose.animation.core.animateDp
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.core.updateTransition
-import androidx.compose.animation.graphics.res.animatedVectorResource
-import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
-import androidx.compose.animation.graphics.vector.AnimatedImageVector
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-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.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.HelpOutline
-import androidx.compose.material.icons.rounded.Search
-import androidx.compose.material.icons.rounded.Settings
-import androidx.compose.material.icons.rounded.Wallpaper
-import androidx.compose.material3.AssistChip
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ElevatedAssistChip
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.livedata.observeAsState
-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.alpha
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.viewmodel.compose.viewModel
-import coil.compose.AsyncImage
-import de.mm20.launcher2.ktx.tryStartActivity
-import de.mm20.launcher2.preferences.LauncherDataStore
-import de.mm20.launcher2.preferences.Settings.SearchBarSettings
-import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarColors
-import de.mm20.launcher2.search.data.Websearch
-import de.mm20.launcher2.ui.R
-import de.mm20.launcher2.ui.component.LauncherCard
-import de.mm20.launcher2.ui.launcher.LauncherActivityVM
-import de.mm20.launcher2.ui.layout.BottomReversed
-import de.mm20.launcher2.ui.locals.LocalCardStyle
-import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
-import de.mm20.launcher2.ui.settings.SettingsActivity
-import kotlinx.coroutines.flow.map
-import org.koin.androidx.compose.inject
-import java.io.File
-
-@Composable
-fun SearchBar(
- modifier: Modifier = Modifier,
- level: () -> SearchBarLevel,
- focused: Boolean,
- onFocusChange: (Boolean) -> Unit,
- reverse: Boolean = false,
-) {
- val searchViewModel: SearchVM = viewModel()
- val activityViewModel: LauncherActivityVM = viewModel()
-
- val dataStore: LauncherDataStore by inject()
-
- val style by remember { dataStore.data.map { it.searchBar.searchBarStyle } }
- .collectAsState(SearchBarSettings.SearchBarStyle.Hidden)
-
- val color by remember { dataStore.data.map { it.searchBar.color } }
- .collectAsState(SearchBarSettings.SearchBarColors.Auto)
-
- val focusManager = LocalFocusManager.current
- val focusRequester = remember { FocusRequester() }
-
- val context = LocalContext.current
-
- LaunchedEffect(focused) {
- if (focused) focusRequester.requestFocus()
- else focusManager.clearFocus()
- }
-
- val query by searchViewModel.searchQuery.observeAsState("")
-
- val websearches by searchViewModel.websearchResults.observeAsState(emptyList())
-
- SearchBar(
- modifier,
- level(),
- websearches,
- value = query,
- onValueChange = {
- searchViewModel.search(it)
- },
- style = style,
- overflowMenu = { show, onDismissRequest ->
- DropdownMenu(expanded = show, onDismissRequest = onDismissRequest) {
- DropdownMenuItem(
- onClick = {
- context.startActivity(
- Intent.createChooser(
- Intent(Intent.ACTION_SET_WALLPAPER),
- null
- )
- )
- onDismissRequest()
- },
- text = {
- Text(stringResource(R.string.wallpaper))
- },
- leadingIcon = {
- Icon(imageVector = Icons.Rounded.Wallpaper, contentDescription = null)
- }
- )
- DropdownMenuItem(
- onClick = {
- context.startActivity(Intent(context, SettingsActivity::class.java))
- onDismissRequest()
- },
- text = {
- Text(stringResource(R.string.settings))
- },
- leadingIcon = {
- Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
- }
- )
- val colorScheme = MaterialTheme.colorScheme
- DropdownMenuItem(
- onClick = {
- CustomTabsIntent.Builder()
- .setDefaultColorSchemeParams(
- CustomTabColorSchemeParams.Builder()
- .setToolbarColor(colorScheme.primaryContainer.toArgb())
- .setSecondaryToolbarColor(colorScheme.secondaryContainer.toArgb())
- .build()
- )
- .build().launchUrl(context, Uri.parse("https://kvaesitso.mm20.de/docs/user-guide"))
- onDismissRequest()
- },
- text = {
- Text(stringResource(R.string.help))
- },
- leadingIcon = {
- Icon(imageVector = Icons.Rounded.HelpOutline, contentDescription = null)
- }
- )
- }
- },
- focusRequester = focusRequester,
- onFocus = {
- onFocusChange(true)
- },
- onUnfocus = {
- onFocusChange(false)
- },
- reverse = reverse,
- darkColors = color == SearchBarColors.Dark || color == SearchBarColors.Auto && LocalPreferDarkContentOverWallpaper.current
- )
-}
-
-@Composable
-fun SearchBar(
- modifier: Modifier = Modifier,
- level: SearchBarLevel,
- websearches: List,
- overflowMenu: @Composable (show: Boolean, onDismissRequest: () -> Unit) -> Unit = { _, _ -> },
- value: String,
- style: SearchBarSettings.SearchBarStyle,
- onValueChange: (String) -> Unit,
- onFocus: () -> Unit = {},
- onUnfocus: () -> Unit = {},
- focusRequester: FocusRequester = remember { FocusRequester() },
- reverse: Boolean = false,
- darkColors: Boolean = false,
-) {
- val context = LocalContext.current
-
- var showOverflowMenu by remember { mutableStateOf(false) }
-
- val transition = updateTransition(level, label = "Searchbar")
-
-
- val elevation by transition.animateDp(
- label = "elevation",
- transitionSpec = {
- when {
- initialState == SearchBarLevel.Resting -> tween(
- durationMillis = 200,
- delayMillis = 200
- )
-
- targetState == SearchBarLevel.Resting -> tween(durationMillis = 200)
- else -> tween(durationMillis = 500)
- }
- }
- ) {
- when {
- it == SearchBarLevel.Resting && style != SearchBarSettings.SearchBarStyle.Solid -> 0.dp
- it == SearchBarLevel.Raised -> 8.dp
- else -> 2.dp
- }
- }
-
- val backgroundOpacity by transition.animateFloat(label = "backgroundOpacity",
- transitionSpec = {
- when {
- initialState == SearchBarLevel.Resting -> tween(durationMillis = 200)
- targetState == SearchBarLevel.Resting -> tween(
- durationMillis = 200,
- delayMillis = 200
- )
-
- else -> tween(durationMillis = 200)
- }
- }) {
- when {
- it == SearchBarLevel.Active -> LocalCardStyle.current.opacity
- style != SearchBarSettings.SearchBarStyle.Transparent -> 1f
- it == SearchBarLevel.Resting -> 0f
- else -> 1f
- }
- }
-
- val contentColor by transition.animateColor(label = "textColor",
- transitionSpec = {
- when {
- initialState == SearchBarLevel.Resting -> tween(durationMillis = 200)
- targetState == SearchBarLevel.Resting -> tween(
- durationMillis = 200,
- delayMillis = 200
- )
-
- else -> tween(durationMillis = 500)
- }
- }) {
- when {
- style != SearchBarSettings.SearchBarStyle.Transparent -> MaterialTheme.colorScheme.onSurface
- it == SearchBarLevel.Resting -> if (darkColors) Color(0, 0, 0, 180) else Color.White
- else -> MaterialTheme.colorScheme.onSurface
- }
- }
-
- val opacity by transition.animateFloat(label = "opacity") {
- if (style == SearchBarSettings.SearchBarStyle.Hidden && it == SearchBarLevel.Resting) 0f
- else 1f
- }
-
- val rightIcon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_menu_clear)
-
- LauncherCard(
- modifier = modifier
- .alpha(opacity),
- backgroundOpacity = backgroundOpacity,
- elevation = elevation
- ) {
- Column(
- verticalArrangement = if (reverse) Arrangement.BottomReversed else Arrangement.Top
- ) {
- Row(
- modifier = Modifier.height(48.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- modifier = Modifier.padding(12.dp),
- imageVector = Icons.Rounded.Search,
- contentDescription = null,
- tint = contentColor
- )
- Box(
- modifier = Modifier.weight(1f)
- ) {
- if (value.isEmpty()) {
- Text(
- text = stringResource(R.string.search_bar_placeholder),
- style = MaterialTheme.typography.bodyLarge,
- color = contentColor
- )
- }
- LaunchedEffect(level) {
- if (level == SearchBarLevel.Resting) onUnfocus()
- }
- BasicTextField(
- modifier = Modifier
- .onFocusChanged {
- if (it.hasFocus) onFocus()
- }
- .focusRequester(focusRequester)
- .fillMaxWidth(),
- textStyle = MaterialTheme.typography.bodyLarge.copy(
- color = contentColor
- ),
- singleLine = true,
- value = value,
- onValueChange = onValueChange,
- )
- }
- Box {
- IconButton(onClick = {
- if (value.isNotBlank()) onValueChange("")
- else showOverflowMenu = true
- }) {
- Icon(
- painter = rememberAnimatedVectorPainter(
- rightIcon,
- atEnd = value.isNotBlank()
- ),
- contentDescription = null,
- tint = contentColor
- )
- }
- overflowMenu(showOverflowMenu) { showOverflowMenu = false }
- }
- }
- AnimatedVisibility(websearches.isNotEmpty()) {
- LazyRow(
- modifier = Modifier
- .height(48.dp)
- .padding(bottom = 12.dp, top = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
- contentPadding = PaddingValues(horizontal = 8.dp)
- ) {
- items(websearches) {
- AssistChip(
- modifier = Modifier.padding(horizontal = 4.dp),
- onClick = {
- it
- .getLaunchIntent()
- ?.let {
- context.tryStartActivity(it)
- }
- },
- label = { Text(it.label) },
- leadingIcon = {
- val icon = it.icon
- if (icon == null) {
- Icon(
- imageVector = Icons.Rounded.Search,
- contentDescription = null,
- tint = if (it.color == 0) MaterialTheme.colorScheme.primary else Color(
- it.color
- )
- )
- } else {
- AsyncImage(
- modifier = Modifier.size(24.dp),
- model = File(icon),
- contentDescription = null
- )
- }
- }
- )
- }
- }
- }
- }
- }
-}
-
-enum class SearchBarLevel {
- /**
- * The default, "hidden" state, when the launcher is in its initial state (scroll position is 0
- * and search is closed)
- */
- Resting,
-
- /**
- * When the search is open but there is no content behind the search bar (scroll position is 0)
- */
- Active,
-
- /**
- * When there is content below the search bar which requires the search bar to be raised above
- * this content (scroll position is not 0)
- */
- Raised
-}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt
index 1621f7f6..cc528e35 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt
@@ -3,7 +3,6 @@ package de.mm20.launcher2.ui.launcher.search
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.permissions.PermissionGroup
@@ -12,9 +11,25 @@ import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.WebsearchRepository
-import de.mm20.launcher2.search.data.*
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.*
+import de.mm20.launcher2.search.data.AppShortcut
+import de.mm20.launcher2.search.data.Calculator
+import de.mm20.launcher2.search.data.CalendarEvent
+import de.mm20.launcher2.search.data.Contact
+import de.mm20.launcher2.search.data.File
+import de.mm20.launcher2.search.data.LauncherApp
+import de.mm20.launcher2.search.data.UnitConverter
+import de.mm20.launcher2.search.data.Website
+import de.mm20.launcher2.search.data.Wikipedia
+import de.mm20.launcher2.searchactions.actions.SearchAction
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -41,7 +56,7 @@ class SearchVM : ViewModel(), KoinComponent {
val websiteResults = MutableLiveData>(emptyList())
val calculatorResults = MutableLiveData>(emptyList())
val unitConverterResults = MutableLiveData>(emptyList())
- val websearchResults = MutableLiveData>(emptyList())
+ val searchActionResults = MutableLiveData>(emptyList())
val hiddenResults = MutableLiveData>(emptyList())
@@ -71,9 +86,6 @@ class SearchVM : ViewModel(), KoinComponent {
searchJob = viewModelScope.launch {
isSearching.postValue(true)
- websearchResults.value = websearchRepository.search(query).first()
-
-
dataStore.data.collectLatest {
searchService.search(
query,
@@ -85,6 +97,7 @@ class SearchVM : ViewModel(), KoinComponent {
shortcuts = it.appShortcutSearch,
websites = it.websiteSearch,
wikipedia = it.wikipediaSearch,
+ searchActions = it.searchActions,
).collectLatest { results ->
hiddenItemKeys.collectLatest { hiddenKeys ->
val hidden = mutableListOf()
@@ -98,11 +111,13 @@ class SearchVM : ViewModel(), KoinComponent {
val calc = mutableListOf()
val wikipedia = mutableListOf()
val website = mutableListOf()
+ val actions = mutableListOf()
for (r in results) {
when {
r is SavableSearchable && hiddenKeys.contains(r.key) -> {
hidden.add(r)
}
+
r is LauncherApp && !r.isMainProfile -> workApps.add(r)
r is LauncherApp -> apps.add(r)
r is AppShortcut -> shortcuts.add(r)
@@ -113,8 +128,10 @@ class SearchVM : ViewModel(), KoinComponent {
r is Calculator -> calc.add(r)
r is Website -> website.add(r)
r is Wikipedia -> wikipedia.add(r)
+ r is SearchAction -> actions.add(r)
}
}
+ searchActionResults.value = actions
appResults.value = apps
workAppResults.value = workApps
appShortcutResults.value = shortcuts
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt
new file mode 100644
index 00000000..a80334ca
--- /dev/null
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt
@@ -0,0 +1,53 @@
+package de.mm20.launcher2.ui.launcher.searchbar
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import de.mm20.launcher2.preferences.Settings
+import de.mm20.launcher2.searchactions.actions.SearchAction
+import de.mm20.launcher2.ui.component.SearchBar
+import de.mm20.launcher2.ui.component.SearchBarLevel
+
+@Composable
+fun LauncherSearchBar(
+ modifier: Modifier = Modifier,
+ style: Settings.SearchBarSettings.SearchBarStyle,
+ level: () -> SearchBarLevel,
+ value: () -> String,
+ onValueChange: (String) -> Unit,
+ focused: Boolean,
+ onFocusChange: (Boolean) -> Unit,
+ actions: List,
+ reverse: Boolean = false,
+ darkColors: Boolean = false,
+) {
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+
+
+ LaunchedEffect(focused) {
+ if (focused) focusRequester.requestFocus()
+ else focusManager.clearFocus()
+ }
+
+ val _value = value()
+
+ SearchBar(
+ modifier = modifier,
+ style = style, level = level(), value = _value, onValueChange = onValueChange,
+ reverse = reverse,
+ darkColors = darkColors,
+ menu = {
+ SearchBarMenu(searchBarValue = _value, onSearchBarValueChange = onValueChange)
+ },
+ actions = {
+ SearchBarActions(actions = actions, reverse = reverse)
+ },
+ focusRequester = focusRequester,
+ onFocus = { onFocusChange(true) },
+ onUnfocus = { onFocusChange(false) },
+ )
+}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt
new file mode 100644
index 00000000..bb854b1b
--- /dev/null
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt
@@ -0,0 +1,100 @@
+package de.mm20.launcher2.ui.launcher.searchbar
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.PaddingValues
+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
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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
+
+@Composable
+fun SearchBarActions(
+ modifier: Modifier = Modifier,
+ actions: List,
+ reverse: Boolean = false,
+) {
+ val context = LocalContext.current
+ AnimatedVisibility(actions.isNotEmpty()) {
+ LazyRow(
+ modifier = Modifier
+ .height(48.dp)
+ .padding(bottom = if (reverse) 4.dp else 12.dp, top = if (reverse) 12.dp else 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ contentPadding = PaddingValues(horizontal = 8.dp)
+ ) {
+ items(actions) {
+ AssistChip(
+ modifier = Modifier.padding(horizontal = 4.dp),
+ onClick = {
+ it.start(context)
+ },
+ 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
+ )
+ )
+ }
+ }
+ /*leadingIcon = {
+ val icon = it.icon
+ if (icon == null) {
+ Icon(
+ imageVector = Icons.Rounded.Search,
+ contentDescription = null,
+ tint = if (it.color == 0) MaterialTheme.colorScheme.primary else Color(
+ it.color
+ )
+ )
+ } else {
+ AsyncImage(
+ modifier = Modifier.size(24.dp),
+ model = File(icon),
+ contentDescription = null
+ )
+ }
+ }*/
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt
new file mode 100644
index 00000000..632b1584
--- /dev/null
+++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt
@@ -0,0 +1,107 @@
+package de.mm20.launcher2.ui.launcher.searchbar
+
+import android.content.Intent
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabColorSchemeParams
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.animation.graphics.res.animatedVectorResource
+import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
+import androidx.compose.animation.graphics.vector.AnimatedImageVector
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.HelpOutline
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.Wallpaper
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import de.mm20.launcher2.ui.R
+import de.mm20.launcher2.ui.settings.SettingsActivity
+
+@Composable
+fun RowScope.SearchBarMenu(
+ searchBarValue: String,
+ onSearchBarValueChange: (newValue: String) -> Unit,
+) {
+ val context = LocalContext.current
+ var showOverflowMenu by remember { mutableStateOf(false) }
+ val rightIcon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_menu_clear)
+
+ IconButton(onClick = {
+ if (searchBarValue.isNotBlank()) onSearchBarValueChange("")
+ else showOverflowMenu = true
+ }) {
+ Icon(
+ painter = rememberAnimatedVectorPainter(
+ rightIcon,
+ atEnd = searchBarValue.isNotEmpty()
+ ),
+ contentDescription = null,
+ tint = LocalContentColor.current
+ )
+ }
+ DropdownMenu(expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false }) {
+ DropdownMenuItem(
+ onClick = {
+ context.startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SET_WALLPAPER),
+ null
+ )
+ )
+ showOverflowMenu = false
+ },
+ text = {
+ Text(stringResource(R.string.wallpaper))
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Rounded.Wallpaper, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ onClick = {
+ context.startActivity(Intent(context, SettingsActivity::class.java))
+ showOverflowMenu = false
+ },
+ text = {
+ Text(stringResource(R.string.settings))
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
+ }
+ )
+ val colorScheme = MaterialTheme.colorScheme
+ DropdownMenuItem(
+ onClick = {
+ CustomTabsIntent.Builder()
+ .setDefaultColorSchemeParams(
+ CustomTabColorSchemeParams.Builder()
+ .setToolbarColor(colorScheme.primaryContainer.toArgb())
+ .setSecondaryToolbarColor(colorScheme.secondaryContainer.toArgb())
+ .build()
+ )
+ .build()
+ .launchUrl(context, Uri.parse("https://kvaesitso.mm20.de/docs/user-guide"))
+ showOverflowMenu = false
+ },
+ text = {
+ Text(stringResource(R.string.help))
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Rounded.HelpOutline, contentDescription = null)
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt
index 9d52a703..9958b77d 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt
@@ -1,13 +1,15 @@
package de.mm20.launcher2.ui.settings
-import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
-import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
-import androidx.compose.runtime.*
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.navigation.navArgument
import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.composable
@@ -16,7 +18,6 @@ import de.mm20.launcher2.licenses.AppLicense
import de.mm20.launcher2.licenses.OpenSourceLicenses
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
-import de.mm20.launcher2.ui.theme.LauncherTheme
import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalNavController
@@ -43,11 +44,13 @@ import de.mm20.launcher2.ui.settings.license.LicenseScreen
import de.mm20.launcher2.ui.settings.main.MainSettingsScreen
import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
+import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen
import de.mm20.launcher2.ui.settings.unitconverter.UnitConverterSettingsScreen
import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.websearch.WebSearchSettingsScreen
import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen
import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen
+import de.mm20.launcher2.ui.theme.LauncherTheme
import de.mm20.launcher2.ui.theme.wallpaperColorsAsState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@@ -119,6 +122,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/websearch") {
WebSearchSettingsScreen()
}
+ composable("settings/search/searchactions") {
+ SearchActionsSettingsScreen()
+ }
composable("settings/search/hiddenitems") {
HiddenItemsSettingsScreen()
}
@@ -164,7 +170,8 @@ class SettingsActivity : BaseActivity() {
composable("settings/debug/crashreporter") {
CrashReporterScreen()
}
- composable("settings/debug/crashreporter/report?fileName={fileName}",
+ composable(
+ "settings/debug/crashreporter/report?fileName={fileName}",
arguments = listOf(navArgument("fileName") {
nullable = false
})
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt
index 01fe1275..f21295ed 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/appearance/AppearanceSettingsScreen.kt
@@ -38,11 +38,11 @@ import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarColors
import de.mm20.launcher2.preferences.Settings.SearchBarSettings.SearchBarStyle
import de.mm20.launcher2.preferences.Settings.SystemBarsSettings.SystemBarColors
import de.mm20.launcher2.ui.R
+import de.mm20.launcher2.ui.component.SearchBar
+import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.getShape
import de.mm20.launcher2.ui.component.preferences.*
-import de.mm20.launcher2.ui.launcher.search.SearchBar
-import de.mm20.launcher2.ui.launcher.search.SearchBarLevel
import de.mm20.launcher2.ui.locals.LocalNavController
import de.mm20.launcher2.ui.theme.getTypography
import kotlinx.coroutines.delay
@@ -389,7 +389,6 @@ fun SearchBarStylePreference(
modifier = Modifier.padding(8.dp),
level = level,
style = styles[it],
- websearches = emptyList(),
value = previewSearchValue,
onValueChange = {})
}
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt
index 21c62f28..615da22f 100644
--- a/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/search/SearchSettingsScreen.kt
@@ -169,17 +169,12 @@ fun SearchSettingsScreen() {
}
)
- val webSearch by viewModel.webSearch.observeAsState()
- PreferenceWithSwitch(
- title = stringResource(R.string.preference_search_websearch),
- summary = stringResource(R.string.preference_search_websearch_summary),
- icon = Icons.Rounded.TravelExplore,
- switchValue = webSearch == true,
- onSwitchChanged = {
- viewModel.setWebSearch(it)
- },
+ Preference(
+ title = stringResource(R.string.preference_screen_search_actions),
+ summary = stringResource(R.string.preference_search_search_actions_summary),
+ icon = Icons.Rounded.ArrowOutward,
onClick = {
- navController?.navigate("settings/search/websearch")
+ navController?.navigate("settings/search/searchactions")
}
)
}
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt
new file mode 100644
index 00000000..78aeb858
--- /dev/null
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreen.kt
@@ -0,0 +1,117 @@
+package de.mm20.launcher2.ui.settings.searchactions
+
+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.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.viewmodel.compose.viewModel
+import de.mm20.launcher2.preferences.Settings
+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.preferences.SwitchPreference
+
+@Composable
+fun SearchActionsSettingsScreen() {
+ val viewModel: SearchActionsSettingsScreenVM = viewModel()
+ val settings by viewModel.searchActionSettings.observeAsState(
+ Settings.SearchActionSettings.getDefaultInstance()
+ )
+
+ 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)
+ }
+ },
+ )
+ SwitchPreference(
+ icon = Icons.Rounded.Sms,
+ title = stringResource(R.string.search_action_message),
+ value = settings.message,
+ 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)
+ }
+ },
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt
new file mode 100644
index 00000000..3e192016
--- /dev/null
+++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/SearchActionsSettingsScreenVM.kt
@@ -0,0 +1,28 @@
+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 org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class SearchActionsSettingsScreenVM : ViewModel(), KoinComponent {
+ private val dataStore: LauncherDataStore by inject()
+
+ val searchActionSettings = dataStore.data.map { it.searchActions }.asLiveData()
+
+ fun updateSettings(block: SearchActionSettings.Builder.() -> SearchActionSettings.Builder) {
+ viewModelScope.launch {
+ dataStore.updateData {
+ it.toBuilder()
+ .setSearchActions(
+ it.searchActions.toBuilder().block()
+ ).build()
+ }
+ }
+ }
+}
\ No newline at end of file