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(":wikipedia"))
implementation(project(":database"))
implementation(project(":search-actions"))
// Uncomment this if you want annoying notifications in your debug builds yelling at you how terrible your code is
//debugImplementation(libs.leakcanary)

View File

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

View File

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

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_rows">Number of rows</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>

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

View File

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

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;
}
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(":wikipedia"))
implementation(project(":customattrs"))
implementation(project(":search-actions"))
implementation(project(":base"))
implementation(project(":database"))

View File

@ -16,6 +16,7 @@ val searchModule = module {
get(),
get(),
get(),
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.ContactsSearchSettings
import de.mm20.launcher2.preferences.Settings.FilesSearchSettings
import de.mm20.launcher2.preferences.Settings.SearchActionSettings
import de.mm20.launcher2.preferences.Settings.UnitConverterSearchSettings
import de.mm20.launcher2.preferences.Settings.WebsiteSearchSettings
import de.mm20.launcher2.preferences.Settings.WikipediaSearchSettings
@ -30,6 +31,8 @@ import de.mm20.launcher2.search.data.OwncloudFile
import de.mm20.launcher2.search.data.UnitConverter
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.searchactions.actions.SearchAction
import de.mm20.launcher2.searchactions.SearchActionService
import de.mm20.launcher2.unitconverter.UnitConverterRepository
import de.mm20.launcher2.websites.WebsiteRepository
import de.mm20.launcher2.wikipedia.WikipediaRepository
@ -56,6 +59,7 @@ interface SearchService {
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
searchActions: SearchActionSettings,
): Flow<ImmutableList<Searchable>>
}
@ -69,6 +73,7 @@ internal class SearchServiceImpl(
private val unitConverterRepository: UnitConverterRepository,
private val calculatorRepository: CalculatorRepository,
private val websiteRepository: WebsiteRepository,
private val searchActionService: SearchActionService,
private val customAttributesRepository: CustomAttributesRepository,
) : SearchService {
@ -82,6 +87,7 @@ internal class SearchServiceImpl(
unitConverter: UnitConverterSearchSettings,
websites: WebsiteSearchSettings,
wikipedia: WikipediaSearchSettings,
searchActions: SearchActionSettings,
): Flow<ImmutableList<Searchable>> = channelFlow {
supervisorScope {
val results = MutableStateFlow(SearchResults())
@ -213,6 +219,14 @@ internal class SearchServiceImpl(
}
}
}
launch {
searchActionService.search(searchActions, query)
.collectLatest { r ->
results.update {
it.copy(searchActions = r)
}
}
}
launch {
results
.map { it.toList().sortedBy { it as? SavableSearchable }.toImmutableList() }
@ -234,9 +248,10 @@ internal data class SearchResults(
val unitConverters: List<UnitConverter> = emptyList(),
val websites: List<Website> = emptyList(),
val wikipedia: List<Wikipedia> = emptyList(),
val searchActions: List<SearchAction> = emptyList(),
val other: List<SavableSearchable> = emptyList(),
) {
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(":accounts"))
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@ -22,12 +21,13 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.component.SearchBarLevel
import de.mm20.launcher2.ui.launcher.LauncherScaffoldVM
import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur
import de.mm20.launcher2.ui.launcher.search.SearchBar
import de.mm20.launcher2.ui.launcher.search.SearchBarLevel
import de.mm20.launcher2.ui.launcher.search.SearchColumn
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar
import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@ -162,9 +162,9 @@ fun AssistantScaffold(
}
val searchVM: SearchVM = viewModel()
val websearches by searchVM.websearchResults.observeAsState(emptyList())
val actions by searchVM.searchActionResults.observeAsState(emptyList())
val webSearchPadding by animateDpAsState(
if (websearches.isEmpty()) 0.dp else 48.dp
if (actions.isEmpty()) 0.dp else 48.dp
)
val windowInsets = WindowInsets.safeDrawing.asPaddingValues()
Box(
@ -182,8 +182,12 @@ fun AssistantScaffold(
state = searchState
)
SearchBar(
level = { searchBarLevel },
val value by searchVM.searchQuery.observeAsState("")
val searchBarColor by viewModel.searchBarColor.observeAsState(Settings.SearchBarSettings.SearchBarColors.Auto)
val searchBarStyle by viewModel.searchBarStyle.observeAsState(Settings.SearchBarSettings.SearchBarStyle.Transparent)
LauncherSearchBar(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
@ -197,10 +201,17 @@ fun AssistantScaffold(
searchBarOffset.toInt() * if (bottomSearchBar == true) -1 else 1
)
},
level = { searchBarLevel },
focused = searchBarFocused,
onFocusChange = {
if (it) viewModel.openSearch()
viewModel.setSearchbarFocus(it)
},
actions = actions,
value = { value },
onValueChange = { searchVM.search(it) },
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == Settings.SearchBarSettings.SearchBarColors.Auto || searchBarColor == Settings.SearchBarSettings.SearchBarColors.Dark,
style = searchBarStyle,
reverse = bottomSearchBar == true
)
}

View File

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

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

View File

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

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

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

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

View File

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

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