Add search quick actions

This commit is contained in:
MM20 2022-11-03 20:01:24 +01:00
parent 9a514dff31
commit f862a578a1
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
58 changed files with 1617 additions and 465 deletions

View File

@ -135,6 +135,7 @@ dependencies {
implementation(project(":widgets")) implementation(project(":widgets"))
implementation(project(":wikipedia")) implementation(project(":wikipedia"))
implementation(project(":database")) 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 // Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is
//debugImplementation(libs.leakcanary) //debugImplementation(libs.leakcanary)

View File

@ -27,6 +27,7 @@ import de.mm20.launcher2.database.databaseModule
import de.mm20.launcher2.notifications.notificationsModule import de.mm20.launcher2.notifications.notificationsModule
import de.mm20.launcher2.permissions.permissionsModule import de.mm20.launcher2.permissions.permissionsModule
import de.mm20.launcher2.preferences.preferencesModule import de.mm20.launcher2.preferences.preferencesModule
import de.mm20.launcher2.searchactions.searchActionsModule
import de.mm20.launcher2.weather.weatherModule import de.mm20.launcher2.weather.weatherModule
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@ -68,6 +69,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory {
permissionsModule, permissionsModule,
preferencesModule, preferencesModule,
searchModule, searchModule,
searchActionsModule,
unitConverterModule, unitConverterModule,
weatherModule, weatherModule,
websitesModule, websitesModule,

View File

@ -8,6 +8,7 @@ buildscript {
dependencies { dependencies {
classpath("com.android.tools.build:gradle:7.3.1") classpath("com.android.tools.build:gradle:7.3.1")
classpath(libs.kotlin.gradle) classpath(libs.kotlin.gradle)
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20")
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
} }

View File

@ -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,
)

View File

@ -676,4 +676,15 @@
<string name="frequently_used_show_in_favorites">Show in favorites</string> <string name="frequently_used_show_in_favorites">Show in favorites</string>
<string name="frequently_used_rows">Number of rows</string> <string name="frequently_used_rows">Number of rows</string>
<string name="customize_tags_placeholder">Tags…</string> <string name="customize_tags_placeholder">Tags…</string>
<string name="preference_screen_search_actions">Quick actions</string>
<string name="preference_search_search_actions_summary">Configure quick actions and search shortcuts</string>
<string name="search_action_call">Call</string>
<string name="search_action_message">Message</string>
<string name="search_action_email">Email</string>
<string name="search_action_alarm">Set alarm</string>
<string name="search_action_timer">Start timer</string>
<string name="search_action_contact">Add to contacts</string>
<string name="search_action_open_url">View website</string>
<string name="search_action_event">Schedule event</string>
</resources> </resources>

View File

@ -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<DataMigration<Settings>> { internal fun getMigrations(context: Context): List<DataMigration<Settings>> {
return listOf( return listOf(
@ -37,5 +37,6 @@ internal fun getMigrations(context: Context): List<DataMigration<Settings>> {
Migration_8_9(), Migration_8_9(),
Migration_9_10(), Migration_9_10(),
Migration_10_11(), Migration_10_11(),
Migration_11_12(),
) )
} }

View File

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

View File

@ -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)
)
}
}

View File

@ -288,4 +288,16 @@ message Settings {
bool edit_button = 1; bool edit_button = 1;
} }
WidgetSettings widgets = 26; 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;
} }

1
search-actions/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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"))
}

View File

21
search-actions/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -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<SearchActionRepository> { SearchActionRepositoryImpl() }
single<SearchActionService> { SearchActionServiceImpl(androidContext(), get(), TextClassifierImpl()) }
}

View File

@ -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<List<SearchActionBuilder>>
}
internal class SearchActionRepositoryImpl: SearchActionRepository {
override fun getSearchActionBuilders(filter: TextType?): Flow<List<SearchActionBuilder>> {
TODO("Not yet implemented")
}
}

View File

@ -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<ImmutableList<SearchAction>>
}
internal class SearchActionServiceImpl(
private val context: Context,
private val repository: SearchActionRepository,
private val textClassifier: TextClassifier,
) : SearchActionService {
override fun search(settings: SearchActionSettings, query: String): Flow<ImmutableList<SearchAction>> = flow {
if (query.isBlank()) {
emit(persistentListOf())
return@flow
}
val builders = mutableListOf<SearchActionBuilder>()
if (settings.call) builders.add(CallActionBuilder)
if (settings.message) builders.add(MessageActionBuilder)
if (settings.contact) builders.add(CreateContactActionBuilder)
if (settings.email) builders.add(EmailActionBuilder)
if (settings.openUrl) builders.add(OpenUrlActionBuilder)
if (settings.scheduleEvent) builders.add(ScheduleEventActionBuilder)
if (settings.setAlarm) builders.add(SetAlarmActionBuilder)
if (settings.startTimer) builders.add(TimerActionBuilder)
val classificationResult = textClassifier.classify(context, query)
emit(builders.mapNotNull { it.build(context, classificationResult) }.toImmutableList())
}
}

View File

@ -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,
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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?
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}
}

View File

@ -57,6 +57,7 @@ dependencies {
implementation(project(":websites")) implementation(project(":websites"))
implementation(project(":wikipedia")) implementation(project(":wikipedia"))
implementation(project(":customattrs")) implementation(project(":customattrs"))
implementation(project(":search-actions"))
implementation(project(":base")) implementation(project(":base"))
implementation(project(":database")) implementation(project(":database"))

View File

@ -16,6 +16,7 @@ val searchModule = module {
get(), get(),
get(), get(),
get(), get(),
get(),
) )
} }
single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) } single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) }

View File

@ -13,6 +13,7 @@ import de.mm20.launcher2.preferences.Settings.CalculatorSearchSettings
import de.mm20.launcher2.preferences.Settings.CalendarSearchSettings import de.mm20.launcher2.preferences.Settings.CalendarSearchSettings
import de.mm20.launcher2.preferences.Settings.ContactsSearchSettings import de.mm20.launcher2.preferences.Settings.ContactsSearchSettings
import de.mm20.launcher2.preferences.Settings.FilesSearchSettings 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.UnitConverterSearchSettings
import de.mm20.launcher2.preferences.Settings.WebsiteSearchSettings import de.mm20.launcher2.preferences.Settings.WebsiteSearchSettings
import de.mm20.launcher2.preferences.Settings.WikipediaSearchSettings 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.UnitConverter
import de.mm20.launcher2.search.data.Website import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia 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.unitconverter.UnitConverterRepository
import de.mm20.launcher2.websites.WebsiteRepository import de.mm20.launcher2.websites.WebsiteRepository
import de.mm20.launcher2.wikipedia.WikipediaRepository import de.mm20.launcher2.wikipedia.WikipediaRepository
@ -56,6 +59,7 @@ interface SearchService {
unitConverter: UnitConverterSearchSettings, unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings, websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings, wikipedia: WikipediaSearchSettings,
searchActions: SearchActionSettings,
): Flow<ImmutableList<Searchable>> ): Flow<ImmutableList<Searchable>>
} }
@ -69,6 +73,7 @@ internal class SearchServiceImpl(
private val unitConverterRepository: UnitConverterRepository, private val unitConverterRepository: UnitConverterRepository,
private val calculatorRepository: CalculatorRepository, private val calculatorRepository: CalculatorRepository,
private val websiteRepository: WebsiteRepository, private val websiteRepository: WebsiteRepository,
private val searchActionService: SearchActionService,
private val customAttributesRepository: CustomAttributesRepository, private val customAttributesRepository: CustomAttributesRepository,
) : SearchService { ) : SearchService {
@ -82,6 +87,7 @@ internal class SearchServiceImpl(
unitConverter: UnitConverterSearchSettings, unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings, websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings, wikipedia: WikipediaSearchSettings,
searchActions: SearchActionSettings,
): Flow<ImmutableList<Searchable>> = channelFlow { ): Flow<ImmutableList<Searchable>> = channelFlow {
supervisorScope { supervisorScope {
val results = MutableStateFlow(SearchResults()) 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 { launch {
results results
.map { it.toList().sortedBy { it as? SavableSearchable }.toImmutableList() } .map { it.toList().sortedBy { it as? SavableSearchable }.toImmutableList() }
@ -234,9 +248,10 @@ internal data class SearchResults(
val unitConverters: List<UnitConverter> = emptyList(), val unitConverters: List<UnitConverter> = emptyList(),
val websites: List<Website> = emptyList(), val websites: List<Website> = emptyList(),
val wikipedia: List<Wikipedia> = emptyList(), val wikipedia: List<Wikipedia> = emptyList(),
val searchActions: List<SearchAction> = emptyList(),
val other: List<SavableSearchable> = emptyList(), val other: List<SavableSearchable> = emptyList(),
) { ) {
fun toList(): List<Searchable> { fun toList(): List<Searchable> {
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
} }
} }

View File

@ -287,3 +287,4 @@ dependencyResolutionManagement {
} }
} }
} }
include(":search-actions")

View File

@ -141,4 +141,5 @@ dependencies {
implementation(project(":owncloud")) implementation(project(":owncloud"))
implementation(project(":accounts")) implementation(project(":accounts"))
implementation(project(":backup")) implementation(project(":backup"))
implementation(project(":search-actions"))
} }

View File

@ -8,7 +8,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@ -22,12 +21,13 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings 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.LauncherScaffoldVM
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur 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.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM 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.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -162,9 +162,9 @@ fun AssistantScaffold(
} }
val searchVM: SearchVM = viewModel() val searchVM: SearchVM = viewModel()
val websearches by searchVM.websearchResults.observeAsState(emptyList()) val actions by searchVM.searchActionResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState( 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() val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
Box( Box(
@ -182,8 +182,12 @@ fun AssistantScaffold(
state = searchState state = searchState
) )
SearchBar( val value by searchVM.searchQuery.observeAsState("")
level = { searchBarLevel },
val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
LauncherSearchBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
@ -197,10 +201,17 @@ fun AssistantScaffold(
searchBarOffset.toInt() * if (bottomSearchBar == true) -1 else 1 searchBarOffset.toInt() * if (bottomSearchBar == true) -1 else 1
) )
}, },
level = { searchBarLevel },
focused = searchBarFocused, focused = searchBarFocused,
onFocusChange = { onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it) 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 reverse = bottomSearchBar == true
) )
} }

View File

@ -42,7 +42,7 @@ fun LauncherCard(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.surface.copy(alpha = backgroundOpacity.coerceIn(0f, 1f)), color = MaterialTheme.colorScheme.surface.copy(alpha = backgroundOpacity.coerceIn(0f, 1f)),
shadowElevation = if (backgroundOpacity == 1f) elevation else 0.dp, shadowElevation = if (backgroundOpacity == 1f) elevation else 0.dp,
tonalElevation = elevation tonalElevation = elevation,
) )
} }

View File

@ -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
}

View File

@ -52,4 +52,6 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
val wallpaperBlur = dataStore.data.map { it.appearance.blurWallpaper }.asLiveData() val wallpaperBlur = dataStore.data.map { it.appearance.blurWallpaper }.asLiveData()
val fillClockHeight = dataStore.data.map { it.clockWidget.fillHeight }.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()
} }

View File

@ -1,6 +1,5 @@
package de.mm20.launcher2.ui.launcher package de.mm20.launcher2.ui.launcher
import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@ -8,7 +7,24 @@ import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut import androidx.compose.animation.slideOut
import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.LocalOverscrollConfiguration
import androidx.compose.foundation.gestures.Orientation 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.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.icons.rounded.Done
import androidx.compose.material.rememberSwipeableState import androidx.compose.material.rememberSwipeableState
import androidx.compose.material.swipeable import androidx.compose.material.swipeable
import androidx.compose.material3.* import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.runtime.* 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.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -37,15 +63,18 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController 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.R
import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur 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.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM 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.WidgetColumn
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import de.mm20.launcher2.ui.utils.rememberNotificationShadeController import de.mm20.launcher2.ui.utils.rememberNotificationShadeController
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -64,6 +93,8 @@ fun PagerScaffold(
val isSearchOpen by viewModel.isSearchOpen.observeAsState(false) val isSearchOpen by viewModel.isSearchOpen.observeAsState(false)
val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false) val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false)
val actions by searchVM.searchActionResults.observeAsState(emptyList())
val widgetsScrollState = rememberScrollState() val widgetsScrollState = rememberScrollState()
val searchState = rememberLazyListState() val searchState = rememberLazyListState()
val swipeableState = rememberSwipeableState(if (isSearchOpen) Page.Search else Page.Widgets) val swipeableState = rememberSwipeableState(if (isSearchOpen) Page.Search else Page.Widgets)
@ -76,7 +107,8 @@ fun PagerScaffold(
val isSearchAtEnd by remember { val isSearchAtEnd by remember {
derivedStateOf { 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 lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding
} }
} }
@ -179,9 +211,11 @@ fun PagerScaffold(
viewModel.closeSearch() viewModel.closeSearch()
searchVM.search("") searchVM.search("")
} }
isWidgetEditMode -> { isWidgetEditMode -> {
viewModel.setWidgetEditMode(false) viewModel.setWidgetEditMode(false)
} }
widgetsScrollState.value != 0 -> { widgetsScrollState.value != 0 -> {
scope.launch { scope.launch {
widgetsScrollState.animateScrollTo(0) widgetsScrollState.animateScrollTo(0)
@ -226,14 +260,16 @@ fun PagerScaffold(
} }
} }
val searchNestedScrollConnection = remember { object: NestedScrollConnection { val searchNestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (source == NestedScrollSource.Drag && available.y.absoluteValue > available.x.absoluteValue * 2) { if (source == NestedScrollSource.Drag && available.y.absoluteValue > available.x.absoluteValue * 2) {
keyboardController?.hide() keyboardController?.hide()
} }
return super.onPreScroll(available, source) return super.onPreScroll(available, source)
} }
}} }
}
val insets = WindowInsets.safeDrawing.asPaddingValues() val insets = WindowInsets.safeDrawing.asPaddingValues()
@ -296,7 +332,7 @@ fun PagerScaffold(
val clockHeight by remember { val clockHeight by remember {
derivedStateOf { derivedStateOf {
if (fillClockHeight){ if (fillClockHeight) {
height - (64.dp + insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding) height - (64.dp + insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding)
} else { } else {
null null
@ -338,10 +374,8 @@ fun PagerScaffold(
) )
} }
val websearches by searchVM.websearchResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState( 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() val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
SearchColumn( SearchColumn(
@ -398,17 +432,29 @@ fun PagerScaffold(
if (isWidgetEditMode) 128.dp else 0.dp 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 modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) .padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding() .imePadding()
.offset(y = widgetEditModeOffset), .offset(y = widgetEditModeOffset),
level = { searchBarLevel }, focused = focusSearchBar, onFocusChange = { level = { searchBarLevel },
focused = focusSearchBar,
onFocusChange = {
if (it) viewModel.openSearch() if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it) viewModel.setSearchbarFocus(it)
}, },
actions = actions,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
style = searchBarStyle,
reverse = true reverse = true
) )
} }

View File

@ -34,15 +34,17 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.R 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.ktx.animateTo
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur 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.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM 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.WidgetColumn
import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -58,6 +60,8 @@ fun PullDownScaffold(
val density = LocalDensity.current val density = LocalDensity.current
val actions by searchVM.searchActionResults.observeAsState(emptyList())
val isSearchOpen by viewModel.isSearchOpen.observeAsState(false) val isSearchOpen by viewModel.isSearchOpen.observeAsState(false)
val isWidgetEditMode by viewModel.isWidgetEditMode.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( 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() val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
SearchColumn( SearchColumn(
@ -392,8 +395,12 @@ fun PullDownScaffold(
if (isWidgetEditMode) -128.dp else 0.dp if (isWidgetEditMode) -128.dp else 0.dp
) )
SearchBar( val value by searchVM.searchQuery.observeAsState("")
level = { searchBarLevel },
val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
LauncherSearchBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
@ -410,11 +417,17 @@ fun PullDownScaffold(
.roundToInt() .roundToInt()
}) })
}, },
level = { searchBarLevel },
focused = searchBarFocused, focused = searchBarFocused,
onFocusChange = { onFocusChange = {
if (it) viewModel.openSearch() if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it) 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,
) )
} }

View File

@ -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<Websearch>,
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
}

View File

@ -3,7 +3,6 @@ package de.mm20.launcher2.ui.launcher.search
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.favorites.FavoritesRepository import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.permissions.PermissionGroup 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.SavableSearchable
import de.mm20.launcher2.search.SearchService import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.WebsearchRepository import de.mm20.launcher2.search.WebsearchRepository
import de.mm20.launcher2.search.data.* import de.mm20.launcher2.search.data.AppShortcut
import kotlinx.coroutines.* import de.mm20.launcher2.search.data.Calculator
import kotlinx.coroutines.flow.* 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -41,7 +56,7 @@ class SearchVM : ViewModel(), KoinComponent {
val websiteResults = MutableLiveData<List<Website>>(emptyList()) val websiteResults = MutableLiveData<List<Website>>(emptyList())
val calculatorResults = MutableLiveData<List<Calculator>>(emptyList()) val calculatorResults = MutableLiveData<List<Calculator>>(emptyList())
val unitConverterResults = MutableLiveData<List<UnitConverter>>(emptyList()) val unitConverterResults = MutableLiveData<List<UnitConverter>>(emptyList())
val websearchResults = MutableLiveData<List<Websearch>>(emptyList()) val searchActionResults = MutableLiveData<List<SearchAction>>(emptyList())
val hiddenResults = MutableLiveData<List<SavableSearchable>>(emptyList()) val hiddenResults = MutableLiveData<List<SavableSearchable>>(emptyList())
@ -71,9 +86,6 @@ class SearchVM : ViewModel(), KoinComponent {
searchJob = viewModelScope.launch { searchJob = viewModelScope.launch {
isSearching.postValue(true) isSearching.postValue(true)
websearchResults.value = websearchRepository.search(query).first()
dataStore.data.collectLatest { dataStore.data.collectLatest {
searchService.search( searchService.search(
query, query,
@ -85,6 +97,7 @@ class SearchVM : ViewModel(), KoinComponent {
shortcuts = it.appShortcutSearch, shortcuts = it.appShortcutSearch,
websites = it.websiteSearch, websites = it.websiteSearch,
wikipedia = it.wikipediaSearch, wikipedia = it.wikipediaSearch,
searchActions = it.searchActions,
).collectLatest { results -> ).collectLatest { results ->
hiddenItemKeys.collectLatest { hiddenKeys -> hiddenItemKeys.collectLatest { hiddenKeys ->
val hidden = mutableListOf<SavableSearchable>() val hidden = mutableListOf<SavableSearchable>()
@ -98,11 +111,13 @@ class SearchVM : ViewModel(), KoinComponent {
val calc = mutableListOf<Calculator>() val calc = mutableListOf<Calculator>()
val wikipedia = mutableListOf<Wikipedia>() val wikipedia = mutableListOf<Wikipedia>()
val website = mutableListOf<Website>() val website = mutableListOf<Website>()
val actions = mutableListOf<SearchAction>()
for (r in results) { for (r in results) {
when { when {
r is SavableSearchable && hiddenKeys.contains(r.key) -> { r is SavableSearchable && hiddenKeys.contains(r.key) -> {
hidden.add(r) hidden.add(r)
} }
r is LauncherApp && !r.isMainProfile -> workApps.add(r) r is LauncherApp && !r.isMainProfile -> workApps.add(r)
r is LauncherApp -> apps.add(r) r is LauncherApp -> apps.add(r)
r is AppShortcut -> shortcuts.add(r) r is AppShortcut -> shortcuts.add(r)
@ -113,8 +128,10 @@ class SearchVM : ViewModel(), KoinComponent {
r is Calculator -> calc.add(r) r is Calculator -> calc.add(r)
r is Website -> website.add(r) r is Website -> website.add(r)
r is Wikipedia -> wikipedia.add(r) r is Wikipedia -> wikipedia.add(r)
r is SearchAction -> actions.add(r)
} }
} }
searchActionResults.value = actions
appResults.value = apps appResults.value = apps
workAppResults.value = workApps workAppResults.value = workApps
appShortcutResults.value = shortcuts appShortcutResults.value = shortcuts

View File

@ -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<SearchAction>,
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) },
)
}

View File

@ -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<SearchAction>,
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
)
}
}*/
)
}
}
}
}

View File

@ -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)
}
)
}
}

View File

@ -1,13 +1,15 @@
package de.mm20.launcher2.ui.settings package de.mm20.launcher2.ui.settings
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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 androidx.navigation.navArgument
import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.composable 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.licenses.OpenSourceLicenses
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings 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.base.BaseActivity
import de.mm20.launcher2.ui.locals.LocalCardStyle import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalNavController 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.main.MainSettingsScreen
import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen 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.unitconverter.UnitConverterSettingsScreen
import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.websearch.WebSearchSettingsScreen import de.mm20.launcher2.ui.settings.websearch.WebSearchSettingsScreen
import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen
import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen import de.mm20.launcher2.ui.settings.wikipedia.WikipediaSettingsScreen
import de.mm20.launcher2.ui.theme.LauncherTheme
import de.mm20.launcher2.ui.theme.wallpaperColorsAsState import de.mm20.launcher2.ui.theme.wallpaperColorsAsState
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -119,6 +122,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/websearch") { composable("settings/search/websearch") {
WebSearchSettingsScreen() WebSearchSettingsScreen()
} }
composable("settings/search/searchactions") {
SearchActionsSettingsScreen()
}
composable("settings/search/hiddenitems") { composable("settings/search/hiddenitems") {
HiddenItemsSettingsScreen() HiddenItemsSettingsScreen()
} }
@ -164,7 +170,8 @@ class SettingsActivity : BaseActivity() {
composable("settings/debug/crashreporter") { composable("settings/debug/crashreporter") {
CrashReporterScreen() CrashReporterScreen()
} }
composable("settings/debug/crashreporter/report?fileName={fileName}", composable(
"settings/debug/crashreporter/report?fileName={fileName}",
arguments = listOf(navArgument("fileName") { arguments = listOf(navArgument("fileName") {
nullable = false nullable = false
}) })

View File

@ -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.SearchBarSettings.SearchBarStyle
import de.mm20.launcher2.preferences.Settings.SystemBarsSettings.SystemBarColors import de.mm20.launcher2.preferences.Settings.SystemBarsSettings.SystemBarColors
import de.mm20.launcher2.ui.R 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.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.getShape import de.mm20.launcher2.ui.component.getShape
import de.mm20.launcher2.ui.component.preferences.* 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.locals.LocalNavController
import de.mm20.launcher2.ui.theme.getTypography import de.mm20.launcher2.ui.theme.getTypography
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -389,7 +389,6 @@ fun SearchBarStylePreference(
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
level = level, level = level,
style = styles[it], style = styles[it],
websearches = emptyList(),
value = previewSearchValue, value = previewSearchValue,
onValueChange = {}) onValueChange = {})
} }

View File

@ -169,17 +169,12 @@ fun SearchSettingsScreen() {
} }
) )
val webSearch by viewModel.webSearch.observeAsState() Preference(
PreferenceWithSwitch( title = stringResource(R.string.preference_screen_search_actions),
title = stringResource(R.string.preference_search_websearch), summary = stringResource(R.string.preference_search_search_actions_summary),
summary = stringResource(R.string.preference_search_websearch_summary), icon = Icons.Rounded.ArrowOutward,
icon = Icons.Rounded.TravelExplore,
switchValue = webSearch == true,
onSwitchChanged = {
viewModel.setWebSearch(it)
},
onClick = { onClick = {
navController?.navigate("settings/search/websearch") navController?.navigate("settings/search/searchactions")
} }
) )
} }

View File

@ -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)
}
},
)
}
}
}
}

View File

@ -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()
}
}
}
}