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