Add search quick actions
This commit is contained in:
parent
9a514dff31
commit
f862a578a1
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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>
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
1
search-actions/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
||||
48
search-actions/build.gradle.kts
Normal file
48
search-actions/build.gradle.kts
Normal 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"))
|
||||
|
||||
}
|
||||
0
search-actions/consumer-rules.pro
Normal file
0
search-actions/consumer-rules.pro
Normal file
21
search-actions/proguard-rules.pro
vendored
Normal file
21
search-actions/proguard-rules.pro
vendored
Normal 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
|
||||
4
search-actions/src/main/AndroidManifest.xml
Normal file
4
search-actions/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@ -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()) }
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,7 @@ dependencies {
|
||||
implementation(project(":websites"))
|
||||
implementation(project(":wikipedia"))
|
||||
implementation(project(":customattrs"))
|
||||
implementation(project(":search-actions"))
|
||||
|
||||
implementation(project(":base"))
|
||||
implementation(project(":database"))
|
||||
|
||||
@ -16,6 +16,7 @@ val searchModule = module {
|
||||
get(),
|
||||
get(),
|
||||
get(),
|
||||
get(),
|
||||
)
|
||||
}
|
||||
single<WebsearchRepository> { WebsearchRepositoryImpl(androidContext(), get()) }
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -287,3 +287,4 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
}
|
||||
include(":search-actions")
|
||||
|
||||
@ -141,4 +141,5 @@ dependencies {
|
||||
implementation(project(":owncloud"))
|
||||
implementation(project(":accounts"))
|
||||
implementation(project(":backup"))
|
||||
implementation(project(":search-actions"))
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
205
ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt
Normal file
205
ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) },
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}*/
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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 = {})
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user