Migrate search UI to Jetpack Compose

This commit is contained in:
MM20 2022-02-14 22:40:46 +01:00
parent b997a7cb62
commit 665664df70
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
56 changed files with 3868 additions and 223 deletions

View File

@ -1,15 +1,10 @@
package de.mm20.launcher2.badges
import android.content.Context
import android.util.Log
import de.mm20.launcher2.badges.providers.*
import de.mm20.launcher2.badges.providers.BadgeProvider
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -47,40 +42,42 @@ internal class BadgeRepositoryImpl(private val context: Context) : BadgeReposito
}
override fun getBadge(badgeKey: String): Flow<Badge?> = channelFlow {
badgeProviders.collectLatest { providers ->
if (providers.isEmpty()) {
send(null)
return@collectLatest
}
combine(providers.map { it.getBadge(badgeKey) }) { badges ->
if (badges.all { it == null }) {
return@combine null
withContext(Dispatchers.Default) {
badgeProviders.collectLatest { providers ->
if (providers.isEmpty()) {
send(null)
return@collectLatest
}
val badge = Badge()
var progresses = 0
badges.filterNotNull().forEach {
if (it.icon != null && badge.icon == null) badge.icon = it.icon
if (it.iconRes != null && badge.iconRes == null) badge.iconRes = it.iconRes
it.number?.let { a ->
badge.number?.let { b -> badge.number = a + b } ?: run {
badge.number = a
combine(providers.map { it.getBadge(badgeKey) }) { badges ->
if (badges.all { it == null }) {
return@combine null
}
val badge = Badge()
var progresses = 0
badges.filterNotNull().forEach {
if (it.icon != null && badge.icon == null) badge.icon = it.icon
if (it.iconRes != null && badge.iconRes == null) badge.iconRes = it.iconRes
it.number?.let { a ->
badge.number?.let { b -> badge.number = a + b } ?: run {
badge.number = a
}
}
it.progress?.let { a ->
badge.progress?.let { b ->
badge.progress = a + b
} ?: run {
badge.progress = a
}
progresses++
}
}
it.progress?.let { a ->
badge.progress?.let {
b -> badge.progress = a + b
} ?: run {
badge.progress = a
}
progresses++
if (progresses > 0) {
badge.progress?.let { badge.progress = it / progresses }
}
return@combine badge
}.collectLatest {
send(it)
}
if (progresses > 0) {
badge.progress?.let { badge.progress = it / progresses }
}
return@combine badge
}.collectLatest {
send(it)
}
}
}

View File

@ -26,6 +26,6 @@
<item name="colorOnSurfaceVariant">@android:color/system_neutral2_700</item>
<item name="colorSurfaceInverse">@android:color/system_neutral2_800</item>
<item name="colorOnSurfaceInverse">@android:color/system_neutral2_50</item>
<item name="elevationOverlayEnabled">false</item>
<item name="elevationOverlayEnabled">true</item>
</style>
</resources>

View File

@ -56,6 +56,6 @@
<item name="colorOnSurfaceVariant">@android:color/black</item>
<item name="colorSurfaceInverse">@android:color/black</item>
<item name="colorOnSurfaceInverse">@android:color/white</item>
<item name="elevationOverlayEnabled">false</item>
<item name="elevationOverlayEnabled">true</item>
</style>
</resources>

View File

@ -1,6 +1,5 @@
package de.mm20.launcher2.search.data
import android.Manifest
import android.content.ContentUris
import android.content.Context
import android.content.Intent
@ -10,7 +9,6 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.LayerDrawable
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import androidx.core.database.getStringOrNull
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.blue
import androidx.core.graphics.green
@ -18,14 +16,8 @@ import androidx.core.graphics.red
import de.mm20.launcher2.calendar.R
import de.mm20.launcher2.graphics.TextDrawable
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.text.SimpleDateFormat
import java.util.*
class CalendarEvent(
override val label: String,
@ -53,19 +45,19 @@ class CalendarEvent(
TextDrawable(
day,
color = Color.WHITE,
fontSize = 40 * context.dp,
fontSize = 20 * context.dp,
typeface = Typeface.DEFAULT_BOLD
),
TextDrawable(
month,
color = Color.WHITE,
fontSize = 26 * context.dp,
fontSize = 13 * context.dp,
typeface = Typeface.DEFAULT_BOLD
)
)
val foreground = LayerDrawable(fgLayers)
foreground.setLayerInset(0, 0, 0, 0, (26 * context.dp).toInt())
foreground.setLayerInset(1, 0, (40 * context.dp).toInt(), 0, 0)
foreground.setLayerInset(0, 0, 0, 0, (13 * context.dp).toInt())
foreground.setLayerInset(1, 0, (20 * context.dp).toInt(), 0, 0)
val background = ColorDrawable(getDisplayColor(context, color))
return LauncherIcon(
foreground = foreground,
@ -74,8 +66,9 @@ class CalendarEvent(
)
}
override fun getLaunchIntent(context: Context): Intent? {
return null
override fun getLaunchIntent(context: Context): Intent {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
return Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
companion object {

View File

@ -1,10 +1,12 @@
package de.mm20.launcher2.search.data
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import androidx.core.database.getStringOrNull
@ -14,14 +16,10 @@ import de.mm20.launcher2.graphics.TextDrawable
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.asBitmap
import de.mm20.launcher2.ktx.sp
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.preferences.Settings.IconSettings.LegacyIconBackground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.net.URLEncoder
class Contact(
val id: Long,
@ -29,11 +27,11 @@ class Contact(
val lastName: String,
val displayName: String,
val lookupKey: String,
val phones: Set<String>,
val emails: Set<String>,
val telegram: Set<String>,
val whatsapp: Set<String>,
val postals: Set<String>
val phones: Set<ContactInfo>,
val emails: Set<ContactInfo>,
val telegram: Set<ContactInfo>,
val whatsapp: Set<ContactInfo>,
val postals: Set<ContactInfo>,
) : Searchable() {
override val key: String
get() = "contact://$id"
@ -42,7 +40,7 @@ class Contact(
val summary: String
get() {
return phones.union(emails).joinToString(separator = ", ")
return phones.union(emails).joinToString(separator = ", ") { it.label }
}
override fun getPlaceholderIcon(context: Context): LauncherIcon {
@ -52,17 +50,22 @@ class Contact(
foreground = TextDrawable(
iconText,
Color.WHITE,
fontSize = 40 * context.sp,
fontSize = 20 * context.sp,
typeface = Typeface.DEFAULT_BOLD
),
background = ColorDrawable(ContextCompat.getColor(context, R.color.blue))
)
}
override suspend fun loadIcon(context: Context, size: Int, legacyIconBackground: LegacyIconBackground): LauncherIcon? {
override suspend fun loadIcon(
context: Context,
size: Int,
legacyIconBackground: LegacyIconBackground
): LauncherIcon? {
val contentResolver = context.contentResolver
val bmp = withContext(Dispatchers.IO) {
val uri = ContactsContract.Contacts.getLookupUri(id, lookupKey) ?: return@withContext null
val uri =
ContactsContract.Contacts.getLookupUri(id, lookupKey) ?: return@withContext null
ContactsContract.Contacts.openContactPhotoInputStream(contentResolver, uri, false)
?.asBitmap()
} ?: return null
@ -73,10 +76,13 @@ class Contact(
)
}
override fun getLaunchIntent(context: Context): Intent? {
return null
override fun getLaunchIntent(context: Context): Intent {
val uri =
ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id)
return Intent(Intent.ACTION_VIEW).setData(uri).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
companion object {
internal fun contactById(context: Context, id: Long, rawIds: Set<Long>): Contact? {
val s = "(" + rawIds.joinToString(separator = " OR ",
@ -92,11 +98,11 @@ class Contact(
ContactsContract.Data.CONTENT_URI,
null, s, null, null
) ?: return null
val phones = mutableSetOf<String>()
val emails = mutableSetOf<String>()
val telegram = mutableSetOf<String>()
val whatsapp = mutableSetOf<String>()
val postals = mutableSetOf<String>()
val phones = mutableSetOf<ContactInfo>()
val emails = mutableSetOf<ContactInfo>()
val telegram = mutableSetOf<ContactInfo>()
val whatsapp = mutableSetOf<ContactInfo>()
val postals = mutableSetOf<ContactInfo>()
var firstName = ""
var lastName = ""
var displayName = ""
@ -119,13 +125,28 @@ class Contact(
loop@ while (dataCursor.moveToNext()) {
when (dataCursor.getStringOrNull(mimeTypeColumn)) {
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(emailAddressColumn)?.let { emails.add(it) }
dataCursor.getStringOrNull(emailAddressColumn)?.let {
emails.add(ContactInfo(it, "mailto:$it"))
}
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(numberColumn)?.let {
phones.add(it.replace(Regex("[^+0-9]"), ""))
val phone = it.replace(Regex("[^+0-9]"), "")
phones.add(
ContactInfo(
phone,
"tel:$phone"
)
)
}
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(addressColumn)?.let { postals.add(it) }
dataCursor.getStringOrNull(addressColumn)?.let {
postals.add(
ContactInfo(
it.replace("\n", ", "),
"geo:0,0?q=${URLEncoder.encode(it, "utf8")}"
)
)
}
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
firstName = dataCursor.getStringOrNull(givenNameColumn) ?: ""
lastName = dataCursor.getStringOrNull(familyNameColumn) ?: ""
@ -136,13 +157,23 @@ class Contact(
?: continue@loop
val data3 = dataCursor.getStringOrNull(data3Column)
?: continue@loop
telegram.add("$data1$$data3")
telegram.add(
ContactInfo(data3.substringAfterLast(" "), "tg:openmessage?user_id=$data1")
)
}
"vnd.android.cursor.item/vnd.com.whatsapp.profile" -> {
val data1 = dataCursor.getStringOrNull(data1Column)
?: continue@loop
val dataId = dataCursor.getLong(idColumn)
whatsapp.add("$dataId$+${data1.substringBefore('@')}")
whatsapp.add(
ContactInfo(
"+${data1.substringBefore('@')}",
Uri.withAppendedPath(
ContactsContract.Data.CONTENT_URI,
dataId.toString()
).toString()
)
)
}
}
}
@ -176,4 +207,9 @@ class Contact(
}
}
}
}
data class ContactInfo(
val label: String,
val data: String
)

View File

@ -2,18 +2,17 @@ package de.mm20.launcher2.files
import android.content.Context
import de.mm20.launcher2.files.providers.*
import de.mm20.launcher2.files.providers.GDriveFileProvider
import de.mm20.launcher2.files.providers.LocalFileProvider
import de.mm20.launcher2.files.providers.NextcloudFileProvider
import de.mm20.launcher2.files.providers.OwncloudFileProvider
import de.mm20.launcher2.hiddenitems.HiddenItemsRepository
import de.mm20.launcher2.nextcloud.NextcloudApiHelper
import de.mm20.launcher2.owncloud.OwncloudClient
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.*
import kotlinx.coroutines.*
import de.mm20.launcher2.search.data.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
interface FileRepository {
fun search(query: String): Flow<List<File>>
@ -70,28 +69,16 @@ internal class FileRepositoryImpl(
return@channelFlow
}
//TODO SearchListView crashes if we send too many updates at once. Rewrite this code
// once SearchListView has been replaced with a Jetpack Compose version of itself
providers.collectLatest { providers ->
if (providers.isEmpty()) {
send(emptyList())
return@collectLatest
}
hiddenItems.collectLatest { hiddenItems ->
if (providers.first() is LocalFileProvider) {
val localFiles = providers.first().takeIf { it is LocalFileProvider }?.search(query) ?: emptyList()
delay(300)
if (providers.size > 1) {
val cloudFiles = providers.subList(1, providers.size).map {
async { it.search(query) }
}.awaitAll().flatten()
send(localFiles + cloudFiles)
}
} else {
val files = providers.map {
async { it.search(query) }
}.awaitAll().flatten()
send(files)
val results = mutableListOf<File>()
for (provider in providers) {
results.addAll(provider.search(query))
send(results)
}
}
}

View File

@ -8,6 +8,7 @@
<string name="menu_uninstall">Deinstallieren</string>
<string name="menu_share">Teilen</string>
<string name="app_info">Version %1$s\n%2$s</string>
<string name="app_info_version">Version %1$s</string>
<string name="settings">Einstellungen</string>
<string name="wallpaper">Hintergrundbild</string>
<string name="title_activity_settings">Einstellungen</string>
@ -74,22 +75,22 @@
<string name="file_type_text">Text-Datei</string>
<string name="alert_delete_directory">Das Verzeichnis %1$s wird samt Inhalt unwideruflich gelöscht. Fortfahren?</string>
<string name="alert_delete_file">Die Datei %1$s wird unwideruflich gelöscht. Fortfahren?</string>
<string name="file_meta_title">Titel</string>
<string name="file_meta_artist">Künstler</string>
<string name="file_meta_album">Album</string>
<string name="file_meta_duration">Länge</string>
<string name="file_meta_year">Jahr</string>
<string name="file_meta_data_entry">%1$s: %2$s</string>
<string name="file_meta_size">Größe</string>
<string name="file_meta_path">Pfad</string>
<string name="file_meta_type">Typ</string>
<string name="file_meta_dimensions">Abmessungen</string>
<string name="file_meta_app_name">App-Name</string>
<string name="file_meta_app_version">Version</string>
<string name="file_meta_app_pkgname">Paketname</string>
<string name="file_meta_app_min_sdk">Min-SDK-Version</string>
<string name="file_meta_title">Titel: %1$s</string>
<string name="file_meta_artist">Künstler: %1$s</string>
<string name="file_meta_album">Album: %1$s</string>
<string name="file_meta_duration">Länge: %1$s</string>
<string name="file_meta_year">Jahr: %1$s</string>
<string name="file_meta_size">Größe: %1$s</string>
<string name="file_meta_path">Pfad: %1$s</string>
<string name="file_meta_type">Typ: %1$s</string>
<string name="file_meta_dimensions">Abmessungen: %1$s</string>
<string name="file_meta_app_name">App-Name: %1$s</string>
<string name="file_meta_app_version">Version: %1$s</string>
<string name="file_meta_app_pkgname">Paketname: %1$s</string>
<string name="file_meta_app_min_sdk">Min-SDK-Version: %1$s</string>
<string name="file_meta_location">Ort: %1$s</string>
<string name="file_meta_owner">Eigentümer: %1$s</string>
<string name="preference_screen_services">Dienste</string>
<string name="file_meta_location">Ort</string>
<string name="websearch_google">Google</string>
<string name="websearch_youtube">YouTube</string>
<string name="websearch_playstore">Google Play</string>
@ -124,11 +125,12 @@
<string name="contact_multiple_emails">%1$d E-Mail-Adressen</string>
<string name="contact_multiple_postals">%1$d Postadressen</string>
<string name="menu_app_info">App-Info</string>
<string name="menu_launch">Starten</string>
<string name="menu_open_file">Öffnen</string>
<string name="preference_screen_services_summary">Verbundene Accounts und Dienste verwalten</string>
<string name="preference_category_services_google">Google</string>
<string name="preference_signin_user">Angemeldet als %1$s</string>
<string name="preference_signin_logout">Abmelden</string>
<string name="file_meta_owner">Eigentümer</string>
<string name="file_type_drawing">Zeichnung</string>
<string name="file_type_ebook">E-Book</string>
<string name="file_type_form">Formular</string>

View File

@ -108,22 +108,22 @@
<string name="file_type_text">Fichier texte</string>
<string name="alert_delete_directory">Le dossier %1$s et ce qu\'il contient vont être définitivement supprimés. Procéder ?</string>
<string name="alert_delete_file">Le fichier %1$s va être définitivement supprimé. Procéder ?</string>
<string name="file_meta_title">Titre</string>
<string name="file_meta_album">Album</string>
<string name="file_meta_duration">Durée</string>
<string name="file_meta_year">Année</string>
<string name="file_meta_data_entry">%1$s: %2$s</string>
<string name="file_meta_size">Taille</string>
<string name="file_meta_path">Chemin d\'accès</string>
<string name="file_meta_type">Type</string>
<string name="file_meta_dimensions">Dimensions</string>
<string name="file_meta_app_name">Nom de l\'application</string>
<string name="file_meta_app_version">Version</string>
<string name="file_meta_app_pkgname">Nom du paquet</string>
<string name="file_meta_app_min_sdk">Version min du SDK</string>
<string name="file_meta_title">Titre: %1$s</string>
<string name="file_meta_album">Album: %1$s</string>
<string name="file_meta_duration">Durée: %1$s</string>
<string name="file_meta_year">Année: %1$s</string>
<string name="file_meta_size">Taille: %1$s</string>
<string name="file_meta_path">Chemin d\'accès: %1$s</string>
<string name="file_meta_type">Type: %1$s</string>
<string name="file_meta_dimensions">Dimensions: %1$s</string>
<string name="file_meta_app_name">Nom de l\'application: %1$s</string>
<string name="file_meta_app_version">Version: %1$s</string>
<string name="file_meta_app_pkgname">Nom du paquet: %1$s</string>
<string name="file_meta_app_min_sdk">Version min du SDK: %1$s</string>
<string name="file_meta_location">Position: %1$s</string>
<string name="file_meta_owner">Propriétaire: %1$s</string>
<string name="preference_screen_services">Services</string>
<string name="preference_theme_system">Suivre le système</string>
<string name="file_meta_location">Position</string>
<string name="websearch_google">Google</string>
<string name="websearch_youtube">YouTube</string>
<string name="websearch_playstore">Google Play</string>
@ -166,7 +166,6 @@
<string name="preference_category_services_google">Google</string>
<string name="preference_signin_user">Connecté en tant que %1$s</string>
<string name="preference_signin_logout">Se déconnecter</string>
<string name="file_meta_owner">Propriétaire</string>
<string name="preference_summary_not_logged_in">Vous n\'êtes pas connecté</string>
<string name="file_type_ebook">E-book</string>
<string name="file_type_drawing">Dessin</string>

View File

@ -15,6 +15,7 @@
<string name="menu_share">Share</string>
<!-- %1$s is the app version, %2$s the package name of an app -->
<string name="app_info">Version %1$s\n%2$s</string>
<string name="app_info_version">Version %1$s</string>
<!-- Menu entry for launcher settings -->
<string name="settings">Settings</string>
<!-- Set the device's wallpaper -->
@ -108,23 +109,23 @@
<string name="file_type_text">Text file</string>
<string name="alert_delete_directory">The directory %1$s and all its content will be deleted permanently. Proceed?</string>
<string name="alert_delete_file">The file %1$s will be deleted permanently. Proceed?</string>
<string name="file_meta_title">Title</string>
<string name="file_meta_artist">Artist</string>
<string name="file_meta_album">Album</string>
<string name="file_meta_duration">Duration</string>
<string name="file_meta_year">Year</string>
<string name="file_meta_data_entry">%1$s: %2$s</string>
<string name="file_meta_size">Size</string>
<string name="file_meta_path">Path</string>
<string name="file_meta_type">Type</string>
<string name="file_meta_dimensions">Dimensions</string>
<string name="file_meta_app_name">App name</string>
<string name="file_meta_app_version">Version</string>
<string name="file_meta_app_pkgname">Package name</string>
<string name="file_meta_app_min_sdk">Min SDK version</string>
<string name="file_meta_title">Title: %1$s</string>
<string name="file_meta_artist">Artist: %1$s</string>
<string name="file_meta_album">Album: %1$s</string>
<string name="file_meta_duration">Duration: %1$s</string>
<string name="file_meta_year">Year: %1$s</string>
<string name="file_meta_size">Size: %1$s</string>
<string name="file_meta_path">Path: %1$s</string>
<string name="file_meta_type">Type: %1$s</string>
<string name="file_meta_dimensions">Dimensions: %1$s</string>
<string name="file_meta_app_name">App name: %1$s</string>
<string name="file_meta_app_version">Version: %1$s</string>
<string name="file_meta_app_pkgname">Package name: %1$s</string>
<string name="file_meta_app_min_sdk">Min SDK version: %1$s</string>
<string name="file_meta_owner">Owner: %1$s</string>
<string name="file_meta_location">Location: %1$s</string>
<string name="preference_screen_services">Services</string>
<string name="preference_theme_system">Follow system</string>
<string name="file_meta_location">Location</string>
<string name="websearch_google">Google</string>
<string name="websearch_youtube">YouTube</string>
<string name="websearch_playstore">Google Play</string>
@ -163,11 +164,12 @@
<string name="contact_multiple_emails">%1$d email addresses</string>
<string name="contact_multiple_postals">%1$d postal addresses</string>
<string name="menu_app_info">App info</string>
<string name="menu_launch">Launch</string>
<string name="menu_open_file">Open</string>
<string name="preference_screen_services_summary">Manage connected accounts and services</string>
<string name="preference_category_services_google">Google</string>
<string name="preference_signin_user">Signed in as %1$s</string>
<string name="preference_signin_logout">Log out</string>
<string name="file_meta_owner">Owner</string>
<string name="preference_summary_not_logged_in">You are currently not logged in</string>
<string name="file_type_ebook">E-book</string>
<string name="file_type_drawing">Drawing</string>

View File

@ -8,8 +8,8 @@
<activity
android:name=".launcher.LauncherActivity"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LauncherTheme"
android:windowSoftInputMode="stateHidden">
<intent-filter>
@ -32,9 +32,9 @@
</activity>
<activity
android:name=".settings.SettingsActivity"
android:exported="true"
android:label="@string/title_activity_settings"
android:launchMode="singleTask"
android:exported="true"
android:parentActivityName=".launcher.LauncherActivity"
android:screenOrientation="portrait"
android:taskAffinity="de.mm20.launcher2.settings"

View File

@ -0,0 +1,56 @@
package de.mm20.launcher2.ui.animation
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateInt
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextGeometricTransform
@Composable
fun animateTextStyleAsState(
textStyle: TextStyle
): State<TextStyle> {
val state = remember { mutableStateOf(textStyle) }
val transition = updateTransition(textStyle, label = "animateTextStyleAsState")
val color by transition.animateColor(label = "color") {
it.color
}
val fontWeight by transition.animateInt(label = "fontWeight") {
it.fontWeight?.weight ?: 400
}
val fontSize by transition.animateTextUnit {
it.fontSize
}
val letterSpacing by transition.animateTextUnit {
it.letterSpacing
}
val baselineShift by transition.animateFloat(label = "baselineShift") {
it.baselineShift?.multiplier ?: 0f
}
val background by transition.animateColor(label = "background") {
it.background
}
val lineHeight by transition.animateTextUnit {
it.lineHeight
}
state.value = textStyle.copy(
color = color,
fontSize = fontSize,
fontWeight = FontWeight(fontWeight),
letterSpacing = letterSpacing,
baselineShift = BaselineShift(baselineShift),
background = background,
lineHeight = lineHeight,
)
return state
}

View File

@ -0,0 +1,44 @@
package de.mm20.launcher2.ui.animation
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
object TextUnitConverter : TwoWayConverter<TextUnit, AnimationVector2D> {
@OptIn(ExperimentalUnitApi::class)
override val convertFromVector: (AnimationVector2D) -> TextUnit
get() = {
TextUnit(
it.v1,
if (it.v2 > 0.5f) TextUnitType.Em else TextUnitType.Sp
)
}
override val convertToVector: (TextUnit) -> AnimationVector2D
get() = {
AnimationVector(
it.value,
if (it.isEm) 1f else 0f
)
}
}
@Composable
inline fun <S> Transition<S>.animateTextUnit(
noinline transitionSpec:
@Composable Transition.Segment<S>.() -> FiniteAnimationSpec<TextUnit> = { spring() },
label: String = "ValueAnimation",
targetValueByState: @Composable (state: S) -> TextUnit
): State<TextUnit> {
return animateValue(
typeConverter = TextUnitConverter,
label = label,
transitionSpec = transitionSpec,
targetValueByState = targetValueByState
)
}

View File

@ -0,0 +1,94 @@
package de.mm20.launcher2.ui.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun Chip(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
content: @Composable RowScope.() -> Unit
) {
Surface(
shape = RoundedCornerShape(4.dp),
modifier = modifier.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.height(32.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.labelMedium,
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
content()
}
}
}
}
@Composable
fun Chip(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit = {},
icon: Painter? = null,
rightIcon: ImageVector? = null,
rightAction: (() -> Unit)? = null
) {
Chip(
modifier = modifier.width(IntrinsicSize.Max),
onClick = onClick
) {
if (icon != null) {
Image(
modifier = Modifier.padding(horizontal = 6.dp).size(20.dp),
painter = icon,
contentDescription = null
)
}
Text(
modifier = Modifier
.weight(1f, false)
.padding(
start = if (icon == null) 12.dp else 4.dp,
end = if (rightIcon == null) 12.dp else 4.dp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = text)
if (rightIcon != null) {
if (rightAction != null) {
IconButton(
modifier = Modifier.size(32.dp),
onClick = rightAction
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = rightIcon,
contentDescription = null
)
}
} else {
Icon(
modifier = Modifier.padding(horizontal = 6.dp).size(20.dp),
imageVector = rightIcon,
contentDescription = null
)
}
}
}
}

View File

@ -0,0 +1,47 @@
package de.mm20.launcher2.ui.component
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.locals.LocalCardStyle
@Composable
fun InnerCard(
modifier: Modifier = Modifier,
raised: Boolean = false,
content: @Composable () -> Unit
) {
val transition = updateTransition(raised, label = "InnerCard")
val elevation by transition.animateDp(label = "elevation", transitionSpec = {
tween(250, if (targetState) 250 else 0)
}) {
if(it) 4.dp else 0.dp
}
val borderWidth by transition.animateDp(label = "borderWidth", transitionSpec = { tween(500) }) {
if (it) 0.dp else 1.dp
}
val borderColor by transition.animateColor(label = "borderColor", transitionSpec = { tween(500) }) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (it) 0f else 1f)
}
Surface(
shadowElevation = elevation,
tonalElevation = elevation,
shape = RoundedCornerShape(LocalCardStyle.current.radius.dp),
border = BorderStroke(borderWidth, borderColor),
content = content,
modifier = modifier
)
}

View File

@ -0,0 +1,336 @@
package de.mm20.launcher2.ui.component
import android.graphics.Matrix
import android.graphics.Path
import android.graphics.RectF
import android.graphics.drawable.AdaptiveIconDrawable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.preferences.Settings.IconSettings.IconShape
import kotlin.math.pow
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShapedLauncherIcon(
modifier: Modifier = Modifier,
size: Dp,
icon: LauncherIcon? = null,
badge: Badge? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
shape: Shape = LocalIconShape.current
) {
Box(
modifier = modifier
.size(size)
) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
clip = true
this.shape = shape
}
.combinedClickable(
enabled = onClick != null || onLongClick != null,
onClick = {
onClick?.invoke()
},
onLongClick = onLongClick,
),
contentAlignment = Alignment.Center
) {
if (icon != null) {
val fgScale = icon.foregroundScale
val bgScale = icon.backgroundScale
Canvas(modifier = Modifier.fillMaxSize()) {
val fg = icon.foreground
val bg = icon.background
drawIntoCanvas {
val paddingFg = (size * (1 - fgScale) * 0.5f).toPx()
val paddingBg = (size * (1 - bgScale) * 0.5f).toPx()
bg?.setBounds(
paddingBg.toInt(),
paddingBg.toInt(),
(this.size.width - paddingBg).toInt(),
(this.size.height - paddingBg).toInt()
)
bg?.draw(it.nativeCanvas)
fg.setBounds(
paddingFg.toInt(),
paddingFg.toInt(),
(this.size.width - paddingFg).toInt(),
(this.size.height - paddingFg).toInt()
)
fg.draw(it.nativeCanvas)
}
}
}
}
if (badge != null) {
Surface(
shadowElevation = 1.dp,
tonalElevation = 1.dp,
modifier = Modifier
.size(size * 0.33f)
.align(Alignment.BottomEnd),
color = MaterialTheme.colorScheme.secondaryContainer,
shape = CircleShape
) {
Box(
contentAlignment = Alignment.Center
) {
val badgeIconRes = badge.iconRes
val badgeIcon = badge.icon
val number = badge.number
if (badgeIconRes != null) {
Image(
modifier = Modifier.fillMaxSize(),
painter = painterResource(badgeIconRes),
contentDescription = null
)
} else if (badgeIcon != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
badgeIcon.setBounds(
0,
0,
this.size.width.roundToInt(),
this.size.height.roundToInt()
)
drawIntoCanvas {
badgeIcon.draw(it.nativeCanvas)
}
}
} else if (number != null && number > 0 && number < 100) {
Text(
number.toString(),
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = with(LocalDensity.current) {
size.toSp() * 0.2f
}
),
)
}
}
}
}
}
}
val LocalIconShape = compositionLocalOf<Shape> { CircleShape }
@Composable
fun ProvideIconShape(iconShape: IconShape, content: @Composable () -> Unit) {
val shape = when (iconShape) {
IconShape.PlatformDefault -> PlatformShape
IconShape.Circle -> CircleShape
IconShape.Square -> RectangleShape
IconShape.RoundedSquare -> RoundedCornerShape(25)
IconShape.Triangle -> TriangleShape
IconShape.Squircle -> SquircleShape
IconShape.Hexagon -> HexagonShape
IconShape.Pentagon -> PentagonShape
IconShape.EasterEgg -> EasterEggShape
IconShape.UNRECOGNIZED -> CircleShape
}
CompositionLocalProvider(
LocalIconShape provides shape,
content = content
)
}
private val TriangleShape: Shape
get() = GenericShape { size, _ ->
var cx = 0f
var cy = size.height * 0.86f
val r = size.width
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 300f, 60f, false)
cx = size.width
cy = size.height * 0.86f
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 180f, 60f, false)
cx = size.width * 0.5f
cy = 0f
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 60f, 60f, false)
close()
}
private val SquircleShape: Shape
get() = GenericShape { size, _ ->
val radius = size.width / 2f
val radiusToPow = radius.pow(3f).toDouble()
moveTo(-radius, 0f)
for (x in -radius.roundToInt()..radius.roundToInt())
lineTo(
x.toFloat(),
Math.cbrt(radiusToPow - Math.abs(x * x * x)).toFloat()
)
for (x in radius.roundToInt() downTo -radius.roundToInt())
lineTo(
x.toFloat(),
(-Math.cbrt(radiusToPow - Math.abs(x * x * x))).toFloat()
)
translate(Offset(size.width / 2f, size.height / 2f))
}
private val HexagonShape: Shape
get() = GenericShape { size, _ ->
moveTo(
size.width * 0.25f,
size.height * 0.933f
)
lineTo(
size.width * 0.75f,
size.height * 0.933f
)
lineTo(
size.width * 1.0f,
size.height * 0.5f
)
lineTo(
size.width * 0.75f,
size.height * 0.067f
)
lineTo(
size.width * 0.25f,
size.height * 0.067f
)
lineTo(0f, size.height * 0.5f)
close()
}
private val PentagonShape: Shape
get() = GenericShape { size, _ ->
moveTo(
0.49997027f * size.width,
0.0060308f * size.height
)
lineTo(
0.99994053f * size.width,
0.36928048f * size.height
)
lineTo(
0.80896887f * size.width,
0.95703078f * size.height
)
lineTo(
0.19097162f * size.width,
0.95703076f * size.height
)
lineTo(
0f,
0.36928045f * size.height
)
close()
}
private val EasterEggShape: Shape
get() = GenericShape { size, _ ->
moveTo(
0.49999999f * size.width,
1f * size.height
)
lineTo(
0.42749999f * size.width,
0.9339999999999999f * size.height
)
cubicTo(
0.16999998f * size.width,
0.7005004f * size.height,
0f,
0.5460004f * size.height,
0f,
0.3575003f * size.height
)
cubicTo(
0f,
0.2030004f * size.height,
0.12100002f * size.width,
0.0825004f * size.height,
0.275f * size.width,
0.0825004f * size.height
)
cubicTo(
0.362f * size.width,
0.0825004f * size.height,
0.4455f * size.width,
0.123f * size.height,
0.5f * size.width,
0.1865003f * size.height
)
cubicTo(
0.55449999f * size.width,
0.123f * size.height,
0.638f * size.width,
0.0825f * size.height,
0.725f * size.width,
0.0825f * size.height
)
cubicTo(
0.87900006f * size.width,
0.0825004f * size.height,
1f * size.width,
0.2030004f * size.height,
1f * size.width,
0.3575003f * size.height
)
cubicTo(
1f * size.width,
0.5460004f * size.height,
0.82999999f * size.width,
0.7005004f * size.height,
0.57250001f * size.width,
0.9340004f * size.height
)
close()
}
private val PlatformShape: Shape
get() {
val drawable = AdaptiveIconDrawable(null, null)
val pathBounds = RectF()
drawable.iconMask.computeBounds(pathBounds, true)
return GenericShape { size, _ ->
val path = Path(drawable.iconMask)
val transformMatrix = Matrix()
transformMatrix.setScale(
size.width / pathBounds.width(),
size.height / pathBounds.height()
)
path.transform(transformMatrix)
addPath(path.asComposePath())
}
}

View File

@ -9,8 +9,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.integerResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.locals.LocalWindowPosition
import kotlin.math.min
@Composable
@ -39,13 +42,19 @@ fun Icons(actions: List<ToolbarAction>, slots: Int) {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Rounded.MoreVert, contentDescription = "")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.animateContentSize()
Box(
modifier = Modifier
.size(48.dp)
.offset(0.dp, LocalWindowPosition.current.toDp())
) {
OverflowMenuItems(items = actions.subList(slots - 1, actions.size)) {
showMenu = false
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.animateContentSize()
) {
OverflowMenuItems(items = actions.subList(slots - 1, actions.size)) {
showMenu = false
}
}
}
}
@ -65,13 +74,19 @@ fun Icons(actions: List<ToolbarAction>, slots: Int) {
}) {
Icon(action.icon, contentDescription = action.label)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.animateContentSize()
Box(
modifier = Modifier
.size(48.dp)
.offset(0.dp, LocalWindowPosition.current.toDp())
) {
OverflowMenuItems(items = action.children) {
showMenu = false
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.animateContentSize()
) {
OverflowMenuItems(items = action.children) {
showMenu = false
}
}
}
}

View File

@ -806,4 +806,54 @@ val Icons.Rounded.Wikipedia
curveTo(15.4f, 18.95f, 14.97f, 18.95f, 14.97f, 18.95f)
close()
}
}
val Icons.Rounded.WhatsApp
get() = materialIcon("Icons.Rounded.WhatsApp") {
materialPath {
moveTo(12.04f, 2f)
curveTo(6.58f, 2f, 2.13f, 6.45f, 2.13f, 11.91f)
curveTo(2.13f, 13.66f, 2.59f, 15.36f, 3.45f, 16.86f)
lineTo(2.05f, 22f)
lineTo(7.3f, 20.62f)
curveTo(8.75f, 21.41f, 10.38f, 21.83f, 12.04f, 21.83f)
curveTo(17.5f, 21.83f, 21.95f, 17.38f, 21.95f, 11.92f)
curveTo(21.95f, 9.27f, 20.92f, 6.78f, 19.05f, 4.91f)
curveTo(17.18f, 3.03f, 14.69f, 2f, 12.04f, 2f)
moveTo(12.05f, 3.67f)
curveTo(14.25f, 3.67f, 16.31f, 4.53f, 17.87f, 6.09f)
curveTo(19.42f, 7.65f, 20.28f, 9.72f, 20.28f, 11.92f)
curveTo(20.28f, 16.46f, 16.58f, 20.15f, 12.04f, 20.15f)
curveTo(10.56f, 20.15f, 9.11f, 19.76f, 7.85f, 19f)
lineTo(7.55f, 18.83f)
lineTo(4.43f, 19.65f)
lineTo(5.26f, 16.61f)
lineTo(5.06f, 16.29f)
curveTo(4.24f, 15f, 3.8f, 13.47f, 3.8f, 11.91f)
curveTo(3.81f, 7.37f, 7.5f, 3.67f, 12.05f, 3.67f)
moveTo(8.53f, 7.33f)
curveTo(8.37f, 7.33f, 8.1f, 7.39f, 7.87f, 7.64f)
curveTo(7.65f, 7.89f, 7f, 8.5f, 7f, 9.71f)
curveTo(7f, 10.93f, 7.89f, 12.1f, 8f, 12.27f)
curveTo(8.14f, 12.44f, 9.76f, 14.94f, 12.25f, 16f)
curveTo(12.84f, 16.27f, 13.3f, 16.42f, 13.66f, 16.53f)
curveTo(14.25f, 16.72f, 14.79f, 16.69f, 15.22f, 16.63f)
curveTo(15.7f, 16.56f, 16.68f, 16.03f, 16.89f, 15.45f)
curveTo(17.1f, 14.87f, 17.1f, 14.38f, 17.04f, 14.27f)
curveTo(16.97f, 14.17f, 16.81f, 14.11f, 16.56f, 14f)
curveTo(16.31f, 13.86f, 15.09f, 13.26f, 14.87f, 13.18f)
curveTo(14.64f, 13.1f, 14.5f, 13.06f, 14.31f, 13.3f)
curveTo(14.15f, 13.55f, 13.67f, 14.11f, 13.53f, 14.27f)
curveTo(13.38f, 14.44f, 13.24f, 14.46f, 13f, 14.34f)
curveTo(12.74f, 14.21f, 11.94f, 13.95f, 11f, 13.11f)
curveTo(10.26f, 12.45f, 9.77f, 11.64f, 9.62f, 11.39f)
curveTo(9.5f, 11.15f, 9.61f, 11f, 9.73f, 10.89f)
curveTo(9.84f, 10.78f, 10f, 10.6f, 10.1f, 10.45f)
curveTo(10.23f, 10.31f, 10.27f, 10.2f, 10.35f, 10.04f)
curveTo(10.43f, 9.87f, 10.39f, 9.73f, 10.33f, 9.61f)
curveTo(10.27f, 9.5f, 9.77f, 8.26f, 9.56f, 7.77f)
curveTo(9.36f, 7.29f, 9.16f, 7.35f, 9f, 7.34f)
curveTo(8.86f, 7.34f, 8.7f, 7.33f, 8.53f, 7.33f)
close()
}
}

View File

@ -108,8 +108,6 @@ class LauncherScaffoldView @JvmOverloads constructor(
ObjectAnimator.ofInt(binding.scrollView, "scrollY", 0).setDuration(200).start()
}
binding.scrollContainer.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
binding.scrollView.scrollY = viewModel.scrollY
binding.scrollView.setOnTouchListener(scrollViewOnTouchListener)
@ -173,7 +171,6 @@ class LauncherScaffoldView @JvmOverloads constructor(
viewModel.setStatusBarColor(colorSurface.data)
} else {
binding.widgetContainer.layoutTransition = ChangingLayoutTransition()
binding.scrollContainer.layoutTransition = ChangingLayoutTransition()
binding.scrollView.setOnTouchListener(scrollViewOnTouchListener)
binding.searchBar.visibility = View.VISIBLE

View File

@ -1,20 +1,92 @@
package de.mm20.launcher2.ui.launcher.search
import android.animation.LayoutTransition
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import de.mm20.launcher2.transition.ChangingLayoutTransition
import de.mm20.launcher2.ui.databinding.ViewSearchBinding
import android.widget.FrameLayout
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.ui.MdcLauncherTheme
import de.mm20.launcher2.ui.component.ProvideIconShape
import de.mm20.launcher2.ui.launcher.search.apps.AppResults
import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorResults
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarResults
import de.mm20.launcher2.ui.launcher.search.contacts.ContactResults
import de.mm20.launcher2.ui.launcher.search.favorites.FavoritesResults
import de.mm20.launcher2.ui.launcher.search.files.FileResults
import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterResults
import de.mm20.launcher2.ui.launcher.search.website.WebsiteResults
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaResults
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.android.ext.android.inject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class SearchView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
val binding = ViewSearchBinding.inflate(LayoutInflater.from(context), this)
) : FrameLayout(context, attrs), KoinComponent {
private val dataStore: LauncherDataStore by inject()
init {
orientation = VERTICAL
layoutTransition = ChangingLayoutTransition()
val view = ComposeView(context)
view.layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT
)
view.setContent {
MdcLauncherTheme {
val cardStyle by remember {
dataStore.data.map { it.cards }.distinctUntilChanged()
}.collectAsState(
Settings.CardSettings.getDefaultInstance()
)
val iconShape by remember {
dataStore.data.map {
if (it.easterEgg) Settings.IconSettings.IconShape.EasterEgg
else it.icons.shape
}.distinctUntilChanged()
}.collectAsState(Settings.IconSettings.IconShape.Circle)
val favoritesEnabled by remember {
dataStore.data.map { it.favorites.enabled }.distinctUntilChanged()
}.collectAsState(true)
CompositionLocalProvider(
LocalCardStyle provides cardStyle,
LocalFavoritesEnabled provides favoritesEnabled
) {
ProvideIconShape(iconShape) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(8.dp)
) {
FavoritesResults()
AppResults()
UnitConverterResults()
CalculatorResults()
CalendarResults()
ContactResults()
FileResults()
WikipediaResults()
WebsiteResults()
}
}
}
}
}
addView(view)
}
}

View File

@ -0,0 +1,341 @@
package de.mm20.launcher2.ui.launcher.search.apps
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
import coil.compose.rememberImagePainter
import com.google.accompanist.flowlayout.FlowRow
import de.mm20.launcher2.search.data.Application
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AppItem(
modifier: Modifier = Modifier,
app: Application,
onBack: () -> Unit
) {
val viewModel = remember { AppItemVM(app) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier = modifier
) {
Row {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
Text(text = app.label, style = MaterialTheme.typography.titleMedium)
app.version?.let {
Text(
text = stringResource(R.string.app_info_version, it),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = app.`package`,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
FlowRow(
modifier = Modifier
.padding(top = 16.dp)
.animateContentSize(),
mainAxisSpacing = 16.dp,
crossAxisSpacing = 8.dp
) {
val notifications by viewModel.notifications.collectAsState(initial = emptyList())
for (not in notifications) {
val title =
not.notification.extras.getString(NotificationCompat.EXTRA_TITLE, null)
?.takeIf { it.isNotBlank() }
?: not.notification.extras.getString(
NotificationCompat.EXTRA_TEXT,
null
)
?.takeIf { it.isNotBlank() }
?: continue
val icon =
remember { not.notification.smallIcon?.loadDrawable(context) }?.let {
rememberImagePainter(
it,
builder = {
crossfade(false)
}
)
}
Chip(
text = title,
icon = icon,
rightIcon = Icons.Rounded.Clear,
rightAction = {
viewModel.clearNotification(not)
},
onClick = {
viewModel.openNotification(not)
}
)
}
for (shortcut in app.shortcuts.subList(0, min(app.shortcuts.size, 5))) {
val title =
shortcut.launcherShortcut.shortLabel
?: shortcut.launcherShortcut.longLabel
?: continue
val isPinned by remember(shortcut) { viewModel.isShortcutPinned(shortcut) }.collectAsState(
false
)
val icon =
remember {
viewModel.getShortcutIcon(
context,
shortcut.launcherShortcut
)
}
?.let {
rememberImagePainter(it,
builder = {
crossfade(false)
})
}
Chip(
text = title.toString(),
icon = icon,
rightIcon = if (LocalFavoritesEnabled.current) {
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline
} else null,
rightAction = {
if (isPinned) {
viewModel.unpinShortcut(shortcut)
} else {
viewModel.pinShortcut(shortcut)
}
},
onClick = {
viewModel.launchShortcut(context, shortcut)
}
)
}
}
}
val badge by viewModel.badge.collectAsState(null)
val iconSize = 84.dp.toPixels().toInt()
val icon by remember(app) { viewModel.getIcon(iconSize) }.collectAsState(null)
ShapedLauncherIcon(
size = 84.dp,
modifier = Modifier
.padding(16.dp),
badge = badge,
icon = icon,
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
viewModel.openAppInfo(context as AppCompatActivity)
})
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_launch),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context as AppCompatActivity)
}
)
)
val storeDetails = remember(app) { app.getStoreDetails(context) }
val shareAction = if (storeDetails == null) {
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share
) {
scope.launch {
viewModel.shareApkFile(context as AppCompatActivity)
}
}
} else {
SubmenuToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
children = listOf(
DefaultToolbarAction(
label = stringResource(R.string.share_menu_store_link, storeDetails.label),
icon = Icons.Rounded.Share,
action = {
viewModel.shareStoreLink(context as AppCompatActivity, storeDetails.url)
}
),
DefaultToolbarAction(
label = stringResource(R.string.share_menu_apk_file),
icon = Icons.Rounded.Share
) {
scope.launch {
viewModel.shareApkFile(context as AppCompatActivity)
}
}
)
)
}
toolbarActions.add(shareAction)
if (viewModel.canUninstall) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_uninstall),
icon = Icons.Rounded.Delete,
) {
viewModel.uninstall(context as AppCompatActivity)
onBack()
}
)
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
})
}
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AppItemGridPopup(
app: Application,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
slideInHorizontally(
tween(300),
initialOffsetX = { -it + origin.width.roundToInt() }) with
slideOutHorizontally(
tween(300),
targetOffsetX = { -it + origin.width.roundToInt() }) + fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
AppItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - 48.dp / 84.dp) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 16.dp * (1 - animationProgress).pow(10),
y = -16.dp * (1 - animationProgress),
),
app = app,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}

View File

@ -0,0 +1,129 @@
package de.mm20.launcher2.ui.launcher.search.apps
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.*
import android.graphics.drawable.Drawable
import android.net.Uri
import android.provider.Settings
import android.service.notification.StatusBarNotification
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.search.data.Application
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class AppItemVM(
private val app: Application
) : SearchableItemVM(app) {
private val notificationRepository: NotificationRepository by inject()
val notifications =
notificationRepository.notifications.map { it.filter { it.packageName == app.`package` } }
fun clearNotification(notification: StatusBarNotification) {
notificationRepository.cancelNotification(notification)
}
fun openAppInfo(activity: AppCompatActivity) {
activity.tryStartActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${app.`package`}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
suspend fun shareApkFile(activity: AppCompatActivity) {
val fileCopy = java.io.File(
activity.cacheDir,
"${app.`package`}-${app.version}.apk"
)
withContext(Dispatchers.IO) {
try {
val info = activity.packageManager
.getApplicationInfo(app.`package`, 0)
val file = java.io.File(info.publicSourceDir)
try {
file.copyTo(fileCopy, false)
} catch (e: FileAlreadyExistsException) {
// Do nothing. If the file is already there we don't have to copy it again.
}
} catch (e: PackageManager.NameNotFoundException) {
CrashReporter.logException(e)
}
}
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
activity,
activity.applicationContext.packageName + ".fileprovider",
fileCopy
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = "application/vnd.android.package-archive"
withContext(Dispatchers.Main) {
activity.startActivity(Intent.createChooser(shareIntent, null))
}
}
fun shareStoreLink(activity: AppCompatActivity, url: String) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_TEXT, url)
shareIntent.type = "text/plain"
activity.startActivity(Intent.createChooser(shareIntent, null))
}
val canUninstall = app.flags and ApplicationInfo.FLAG_SYSTEM == 0
fun uninstall(activity: AppCompatActivity) {
val intent = Intent(Intent.ACTION_DELETE)
intent.data = Uri.parse("package:" + app.`package`)
activity.startActivity(intent)
}
fun openNotification(notification: StatusBarNotification) {
try {
notification.notification.contentIntent.send()
} catch (e: PendingIntent.CanceledException) {}
}
fun getShortcutIcon(context: Context, shortcut: ShortcutInfo) : Drawable? {
val launcherApps = context.getSystemService<LauncherApps>() ?: return null
return launcherApps.getShortcutIconDrawable(shortcut, 0)
}
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> {
return favoritesRepository.isPinned(shortcut)
}
fun pinShortcut(shortcut: AppShortcut) {
favoritesRepository.pinItem(shortcut)
}
fun unpinShortcut(shortcut: AppShortcut) {
favoritesRepository.unpinItem(shortcut)
}
fun launchShortcut(context: Context, shortcut: AppShortcut) {
shortcut.launch(context, null)
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.search.apps
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.SearchResultGrid
@Composable
fun ColumnScope.AppResults() {
val viewModel: SearchVM = viewModel()
val apps by viewModel.appResults.observeAsState(emptyList())
AnimatedVisibility(apps.isNotEmpty()) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
SearchResultGrid(items = apps)
}
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.search.calculator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.search.CalculatorItem
@Composable
fun ColumnScope.CalculatorResults() {
val viewModel: SearchVM = viewModel()
val calculator by viewModel.calculatorResult.observeAsState(null)
AnimatedVisibility(calculator != null) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
calculator?.let { CalculatorItem(calculator = it) }
}
}
}

View File

@ -0,0 +1,260 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
@Composable
fun CalendarItem(
modifier: Modifier = Modifier,
calendar: CalendarEvent,
showDetails: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(calendar.key) { CalendarItemVM(calendar) }
Row(
modifier = modifier
.drawBehind {
drawRect(Color(calendar.color), Offset.Zero, this.size.copy(width = 8.dp.toPx()))
}
.padding(start = 8.dp),
) {
Column(
modifier = Modifier.weight(1f)
) {
Row {
val padding by animateDpAsState(if (showDetails) 16.dp else 12.dp)
Column(
modifier = Modifier.padding(
top = padding,
start = padding,
bottom = 12.dp,
end = padding
)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.titleSmall
)
Text(text = calendar.label, style = textStyle)
AnimatedVisibility(!showDetails) {
Text(
text = viewModel.getSummary(context),
style = MaterialTheme.typography.bodySmall
)
}
}
}
AnimatedVisibility(showDetails) {
Column {
Row(
Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.Schedule,
contentDescription = null
)
Text(
text = viewModel.formatTime(context),
style = MaterialTheme.typography.bodySmall
)
}
if (calendar.description.isNotBlank()) {
Row(
Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.Notes,
contentDescription = null
)
Text(
text = calendar.description,
style = MaterialTheme.typography.bodySmall
)
}
}
if (calendar.attendees.isNotEmpty()) {
Row(
Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.People,
contentDescription = null
)
Text(
text = calendar.attendees.joinToString(),
style = MaterialTheme.typography.bodySmall
)
}
}
if (calendar.location.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.openLocation(context as AppCompatActivity)
}
) {
Icon(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
imageVector = Icons.Rounded.Place,
contentDescription = null
)
Text(
text = calendar.location,
style = MaterialTheme.typography.bodySmall
)
}
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
})
}
toolbarActions.add(hideAction)
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.calendar_menu_open_externally),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context as AppCompatActivity)
onBack()
}
)
)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun CalendarItemGridPopup(
calendar: CalendarEvent,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(snap()) with
fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
CalendarItem(
modifier = Modifier
.fillMaxWidth()
.background(Color(calendar.color).copy(alpha = 1f - animationProgress)),
calendar = calendar,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}

View File

@ -0,0 +1,65 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.format.DateUtils
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import java.net.URLEncoder
class CalendarItemVM(
private val calendarEvent: CalendarEvent
) : SearchableItemVM(calendarEvent) {
fun getSummary(context: Context): String {
val isToday =
DateUtils.isToday(calendarEvent.startTime) && DateUtils.isToday(calendarEvent.endTime)
return if (isToday) {
if (calendarEvent.allDay) {
context.getString(R.string.calendar_event_allday)
} else {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_TIME
)
}
} else {
if (calendarEvent.allDay) {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_DATE
)
} else {
DateUtils.formatDateRange(
context,
calendarEvent.startTime,
calendarEvent.endTime,
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
)
}
}
}
fun formatTime(context: Context): String {
if (calendarEvent.allDay) return DateUtils.formatDateRange(context, calendarEvent.startTime, calendarEvent.endTime, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY)
return DateUtils.formatDateRange(context, calendarEvent.startTime, calendarEvent.endTime, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_WEEKDAY)
}
fun openLocation(context: AppCompatActivity) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse("geo:0,0?q=${URLEncoder.encode(calendarEvent.location, "utf8")}"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.search.calendar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.CalendarResults() {
val viewModel: SearchVM = viewModel()
val calendarEvents by viewModel.calendarResults.observeAsState(emptyList())
AnimatedVisibility(calendarEvents.isNotEmpty()) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth()
) {
SearchResultList(items = calendarEvents)
}
}
}

View File

@ -0,0 +1,65 @@
package de.mm20.launcher2.ui.launcher.search.common
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect
import androidx.core.app.ActivityOptionsCompat
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.data.Searchable
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
abstract class SearchableItemVM(
private val searchable: Searchable
) : KoinComponent {
protected val favoritesRepository: FavoritesRepository by inject()
protected val badgeRepository: BadgeRepository by inject()
protected val iconRepository: IconRepository by inject()
val isPinned = favoritesRepository.isPinned(searchable)
fun pin() {
favoritesRepository.pinItem(searchable)
}
fun unpin() {
favoritesRepository.unpinItem(searchable)
}
val isHidden = favoritesRepository.isHidden(searchable)
fun hide() {
favoritesRepository.hideItem(searchable)
}
fun unhide() {
favoritesRepository.unhideItem(searchable)
}
val badge = badgeRepository.getBadge(searchable.badgeKey)
fun getIcon(size: Int): Flow<LauncherIcon> {
return iconRepository.getIcon(searchable, size)
}
fun launch(context: AppCompatActivity, bounds: Rect? = null): Boolean {
val options = if (bounds != null) {
ActivityOptionsCompat.makeClipRevealAnimation(
context.window.decorView,
bounds.left.toInt(),
bounds.top.toInt(),
bounds.width.toInt(),
bounds.height.toInt()
)
} else {
ActivityOptionsCompat.makeBasic()
}
if (searchable.launch(context, options.toBundle())) {
favoritesRepository.incrementLaunchCounter(searchable)
return true
}
return false
}
}

View File

@ -0,0 +1,221 @@
package de.mm20.launcher2.ui.launcher.search.common
import androidx.activity.compose.BackHandler
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.apps.AppItemGridPopup
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItemGridPopup
import de.mm20.launcher2.ui.launcher.search.common.grid.GridItemVM
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItemGridPopup
import de.mm20.launcher2.ui.launcher.search.files.FileItemGridPopup
import de.mm20.launcher2.ui.launcher.search.shortcut.ShortcutItemGridPopup
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItemGridPopup
import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaItemGridPopup
import de.mm20.launcher2.ui.locals.LocalWindowPosition
import kotlinx.coroutines.delay
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GridItem(modifier: Modifier = Modifier, item: Searchable) {
val viewModel = remember(item.key) { GridItemVM(item) }
val context = LocalContext.current
var showPopup by remember { mutableStateOf(false) }
var bounds by remember { mutableStateOf(Rect.Zero) }
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
val badge by viewModel.badge.collectAsState(null)
val iconSize = 48.dp.toPixels()
val icon by remember(item.key) { viewModel.getIcon(iconSize.toInt()) }.collectAsState(null)
// If item is one of these types, try to launch them on click; show details otherwise
val launchOnPress =
item is File || item is Application || item is AppShortcut || item is Website || item is Wikipedia
ShapedLauncherIcon(
modifier = Modifier
.onGloballyPositioned {
bounds = it.boundsInWindow()
},
size = 48.dp,
badge = badge,
icon = icon,
onClick = {
if (!launchOnPress || !viewModel.launch(context as AppCompatActivity, bounds)) {
showPopup = true
}
},
onLongClick = {
showPopup = true
}
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
text = item.label,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (showPopup) {
ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false })
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class)
@Composable
fun ItemPopup(origin: Rect, searchable: Searchable, onDismissRequest: () -> Unit) {
var show by remember { mutableStateOf(false) }
LaunchedEffect(null) {
show = true
}
LaunchedEffect(show) {
if (!show) {
delay(300L)
onDismissRequest()
}
}
BackHandler {
show = false
}
val animationProgress by animateFloatAsState(if (show) 1f else 0f, tween(300))
Popup(
properties = PopupProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
),
alignment = Alignment.TopCenter,
onDismissRequest = {
show = false
},
offset = IntOffset(-origin.left.toInt(), 0)
) {
CompositionLocalProvider(LocalWindowPosition provides origin.top) {
Box(
modifier = Modifier
.fillMaxWidth()
) {
LauncherCard(
elevation = 8.dp * animationProgress,
backgroundOpacity = 1f,
modifier = Modifier
.padding(horizontal = 16.dp)
.absoluteOffset(
x = ((1 - animationProgress) * origin.left).toDp() - 16.dp * (1 - animationProgress),
)
.wrapContentSize()
) {
when (searchable) {
is Application -> {
AppItemGridPopup(
app = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is Website -> {
WebsiteItemGridPopup(
website = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is Wikipedia -> {
WikipediaItemGridPopup(
wikipedia = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is Contact -> {
ContactItemGridPopup(
contact = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is File -> {
FileItemGridPopup(
file = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is CalendarEvent -> {
CalendarItemGridPopup(
calendar = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
is AppShortcut -> {
ShortcutItemGridPopup(
shortcut = searchable,
show = show,
animationProgress = animationProgress,
origin = origin,
onDismiss = {
show = false
}
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,19 @@
package de.mm20.launcher2.ui.launcher.search.common.grid
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toAndroidRect
import androidx.core.app.ActivityOptionsCompat
import de.mm20.launcher2.badges.BadgeRepository
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import kotlinx.coroutines.flow.Flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class GridItemVM(
private val searchable: Searchable
): SearchableItemVM(searchable)

View File

@ -0,0 +1,55 @@
package de.mm20.launcher2.ui.launcher.search.common
import androidx.compose.animation.*
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import de.mm20.launcher2.search.data.Application
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.launcher.search.apps.AppItemGridPopup
import kotlinx.coroutines.delay
import kotlin.math.ceil
@Composable
fun SearchResultGrid(items: List<Searchable>) {
Column(
modifier = Modifier.animateContentSize().fillMaxWidth().padding(4.dp)
) {
for (i in 0 until ceil(items.size / 5f).toInt()) {
Row {
for (j in 0 until 5) {
val item = items.getOrNull(i * 5 + j)
if (item != null) {
GridItem(
modifier = Modifier
.weight(1f)
.padding(4.dp, 8.dp), item = item
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}

View File

@ -0,0 +1,179 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
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.Searchable
import de.mm20.launcher2.ui.component.InnerCard
import de.mm20.launcher2.ui.launcher.search.calendar.CalendarItem
import de.mm20.launcher2.ui.launcher.search.contacts.ContactItem
import de.mm20.launcher2.ui.launcher.search.files.FileItem
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
@OptIn(ExperimentalMaterialApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun ListItem(modifier: Modifier = Modifier, item: Searchable) {
var showDetails by remember { mutableStateOf(false) }
val context = LocalContext.current
val viewModel = remember(item.key) { ListItemVM(item) }
val isPinned by viewModel.isPinned.collectAsState(false)
val isHidden by viewModel.isHidden.collectAsState(false)
val dismissState = rememberDismissState(
confirmStateChange = {
when (it) {
DismissValue.Default -> {}
DismissValue.DismissedToEnd -> {
if (isPinned) viewModel.unpin()
else viewModel.pin()
}
DismissValue.DismissedToStart -> {
if (isHidden) viewModel.unhide()
else viewModel.hide()
}
}
it == DismissValue.DismissedToStart
}
)
val swipeDirections = when {
showDetails -> emptySet()
LocalFavoritesEnabled.current -> setOf(
DismissDirection.StartToEnd,
DismissDirection.EndToStart
)
else -> setOf(DismissDirection.EndToStart)
}
SwipeToDismiss(
modifier = modifier,
state = dismissState,
directions = swipeDirections,
background = {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(LocalCardStyle.current.radius.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = (dismissState.progress.fraction * 2f).coerceAtMost(
1f
)
)
),
contentAlignment = if (dismissState.dismissDirection == DismissDirection.EndToStart) Alignment.CenterEnd else Alignment.CenterStart
) {
val overThreshold =
dismissState.progress.fraction >= 0.5f && dismissState.dismissDirection != null
val iconScale by animateFloatAsState(
if (overThreshold) 1.25f else 1f
)
val iconColor by animateColorAsState(
if (overThreshold) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
val pinIcon = if (isPinned) Icons.Rounded.StarOutline else Icons.Rounded.Star
val hideIcon =
if (isHidden) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff
Icon(
modifier = Modifier
.padding(16.dp)
.scale(iconScale),
imageVector = if (dismissState.dismissDirection == DismissDirection.EndToStart) {
hideIcon
} else {
pinIcon
},
tint = iconColor,
contentDescription = null
)
}
}
}
) {
var bounds by remember { mutableStateOf(Rect.Zero) }
InnerCard(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned {
bounds = it.boundsInWindow()
},
raised = showDetails
) {
when (item) {
is Contact -> {
ContactItem(
modifier = Modifier.combinedClickable(
enabled = !showDetails,
onClick = { showDetails = true },
onLongClick = { showDetails = true }
),
contact = item,
showDetails = showDetails,
onBack = { showDetails = false }
)
}
is File -> {
FileItem(
modifier = Modifier.combinedClickable(
enabled = !showDetails,
onClick = {
if (!viewModel.launch(context as AppCompatActivity, bounds)) {
showDetails = true
}
},
onLongClick = { showDetails = true }
),
file = item,
showDetails = showDetails,
onBack = { showDetails = false }
)
}
is CalendarEvent -> {
CalendarItem(
modifier = Modifier.combinedClickable(
enabled = !showDetails,
onClick = { showDetails = true },
onLongClick = { showDetails = true }
),
calendar = item,
showDetails = showDetails,
onBack = { showDetails = false }
)
}
}
}
}
}

View File

@ -0,0 +1,9 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class ListItemVM(
private val searchable: Searchable
): SearchableItemVM(searchable) {
}

View File

@ -0,0 +1,31 @@
package de.mm20.launcher2.ui.launcher.search.common.list
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.Searchable
@Composable
fun SearchResultList(items: List<Searchable>) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
for (item in items) {
key(item.key) {
ListItem(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
item = item
)
}
}
}
}

View File

@ -0,0 +1,325 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.icons.Telegram
import de.mm20.launcher2.ui.icons.WhatsApp
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
@OptIn(ExperimentalUnitApi::class)
@Composable
fun ContactItem(
modifier: Modifier = Modifier,
contact: Contact,
showDetails: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(contact) { ContactItemVM(contact) }
val transition = updateTransition(showDetails, label = "ContactItem")
Column(
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val iconSize = 48.dp.toPixels().toInt()
val icon by remember(contact) { viewModel.getIcon(iconSize) }.collectAsState(null)
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(start = padding, top = padding, bottom = padding),
icon = icon,
)
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleLarge
else MaterialTheme.typography.titleSmall
)
Text(
text = contact.label,
style = textStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(!showDetails) {
Text(
contact.summary,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
AnimatedVisibility(showDetails) {
Column {
if (contact.phones.isNotEmpty()) {
Row(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Call, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.phones.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context as AppCompatActivity, it)
}
)
}
}
}
}
if (contact.emails.isNotEmpty()) {
Row(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Email, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.emails.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context as AppCompatActivity, it)
}
)
}
}
}
}
if (contact.telegram.isNotEmpty()) {
Row(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Telegram, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.telegram.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context as AppCompatActivity, it)
}
)
}
}
}
}
if (contact.whatsapp.isNotEmpty()) {
Row(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.WhatsApp, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.whatsapp.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context as AppCompatActivity, it)
}
)
}
}
}
}
if (contact.postals.isNotEmpty()) {
Row(
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.Place, contentDescription = null)
LazyRow(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(contact.postals.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
viewModel.contact(context as AppCompatActivity, it)
}
)
}
}
}
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
})
}
toolbarActions.add(hideAction)
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.calendar_menu_open_externally),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context as AppCompatActivity)
}
)
)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun ContactItemGridPopup(
contact: Contact,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(snap()) with
fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
ContactItem(
modifier = Modifier
.fillMaxWidth()
.offset(
x = -16.dp * (1 - animationProgress),
y = -16.dp * (1 - animationProgress)
),
contact = contact,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}

View File

@ -0,0 +1,20 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.Contact
import de.mm20.launcher2.search.data.ContactInfo
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import org.koin.core.component.KoinComponent
class ContactItemVM(
val contact: Contact
): SearchableItemVM(contact) {
fun contact(context: AppCompatActivity, contactInfo: ContactInfo) {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK).setData(Uri.parse(contactInfo.data))
)
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.ContactResults() {
val viewModel: SearchVM = viewModel()
val contacts by viewModel.contactResults.observeAsState(emptyList())
AnimatedVisibility(contacts.isNotEmpty()) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth()
) {
SearchResultList(items = contacts)
}
}
}

View File

@ -0,0 +1,30 @@
package de.mm20.launcher2.ui.launcher.search.favorites
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.SearchResultGrid
@Composable
fun ColumnScope.FavoritesResults() {
val viewModel: SearchVM = viewModel()
val favorites by viewModel.favorites.observeAsState(emptyList())
val hide by viewModel.hideFavorites.observeAsState(true)
AnimatedVisibility(!hide && favorites.isNotEmpty()) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
SearchResultGrid(items = favorites)
}
}
}

View File

@ -0,0 +1,305 @@
package de.mm20.launcher2.ui.launcher.search.files
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.ExperimentalUnitApi
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LocalFile
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import java.text.DecimalFormat
import kotlin.math.roundToInt
@OptIn(ExperimentalUnitApi::class)
@Composable
fun FileItem(
modifier: Modifier = Modifier,
file: File,
showDetails: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val viewModel = remember(file.key) { FileItemVM(file) }
val transition = updateTransition(showDetails, label = "ContactItem")
Column(
modifier = modifier
) {
Row(
verticalAlignment = Alignment.Top
) {
val textPadding by transition.animateDp(label = "textPadding") {
if (it) 16.dp else 12.dp
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, top = textPadding, end = 16.dp)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.titleSmall
)
Text(
text = file.label,
style = textStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(!showDetails) {
Text(
file.getFileType(context),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility(showDetails) {
Column {
Text(
text = stringResource(
R.string.file_meta_type,
file.mimeType
),
style = MaterialTheme.typography.bodySmall,
)
for ((k, v) in file.metaData) {
Text(
text = stringResource(k, v),
style = MaterialTheme.typography.bodySmall
)
}
if (!file.isDirectory) {
Text(
text = stringResource(
R.string.file_meta_size,
formatFileSize(file.size)
),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
val iconSize = 48.dp.toPixels().toInt()
val icon by remember(file) { viewModel.getIcon(iconSize) }.collectAsState(null)
val badge by viewModel.badge.collectAsState(null)
val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(end = padding, top = padding, bottom = padding),
icon = icon,
badge = badge
)
}
AnimatedVisibility(showDetails) {
Column {
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_open_file),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context as AppCompatActivity)
}
)
)
if (viewModel.canShare) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
viewModel.share(context as AppCompatActivity)
}
))
}
if (viewModel.canDelete) {
var showConfirmDialog by remember { mutableStateOf(false) }
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),
icon = Icons.Rounded.Delete,
action = {
showConfirmDialog = true
}
))
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
confirmButton = {
TextButton(onClick = {
viewModel.delete()
showConfirmDialog = false
}) {
Text(stringResource(android.R.string.ok), style = MaterialTheme.typography.labelLarge)
}
},
dismissButton = {
TextButton(onClick = {
showConfirmDialog = false
}) {
Text(stringResource(android.R.string.cancel), style = MaterialTheme.typography.labelLarge)
}
},
text = {
Text(
if (file.isDirectory) stringResource(
R.string.alert_delete_directory,
file.label
)
else stringResource(R.string.alert_delete_file, file.label)
)
}
)
}
}
val isHidden by viewModel.isHidden.collectAsState(false)
val hideAction = if (isHidden) {
DefaultToolbarAction(
label = stringResource(R.string.menu_unhide),
icon = Icons.Rounded.Visibility,
action = {
viewModel.unhide()
onBack()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_hide),
icon = Icons.Rounded.VisibilityOff,
action = {
viewModel.hide()
onBack()
})
}
toolbarActions.add(hideAction)
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun FileItemGridPopup(
file: File,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
slideInHorizontally(
tween(300),
initialOffsetX = { -it + origin.width.roundToInt() }) with
slideOutHorizontally(
tween(300),
targetOffsetX = { -it + origin.width.roundToInt() }) + fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
FileItem(
modifier = Modifier
.fillMaxWidth()
.offset(
x = 16.dp * (1 - animationProgress),
y = -16.dp * (1 - animationProgress)
),
file = file,
showDetails = true,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}
private fun formatFileSize(size: Long): String {
return when {
size < 1000L -> "$size Bytes"
size < 1000000L -> "${DecimalFormat("#,##0.#").format(size / 1000.0)} kB"
size < 1000000000L -> "${DecimalFormat("#,##0.#").format(size / 1000000.0)} MB"
size < 1000000000000L -> "${DecimalFormat("#,##0.#").format(size / 1000000000.0)} GB"
else -> "${DecimalFormat("#,##0.#").format(size / 1000000000000.0)} TB"
}
}

View File

@ -0,0 +1,36 @@
package de.mm20.launcher2.ui.launcher.search.files
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import de.mm20.launcher2.files.FileRepository
import de.mm20.launcher2.search.data.File
import de.mm20.launcher2.search.data.LocalFile
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import org.koin.core.component.inject
class FileItemVM(
private val file: File
) : SearchableItemVM(file) {
private val fileRepository: FileRepository by inject()
val canShare = file is LocalFile
val canDelete = file.isDeletable
fun delete() {
fileRepository.deleteFile(file)
}
fun share(context: AppCompatActivity) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uri = FileProvider.getUriForFile(
context,
context.applicationContext.packageName + ".fileprovider",
java.io.File(file.path)
)
shareIntent.putExtra(Intent.EXTRA_STREAM, uri)
shareIntent.type = file.mimeType
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.search.files
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
@Composable
fun ColumnScope.FileResults() {
val viewModel: SearchVM = viewModel()
val files by viewModel.fileResults.observeAsState(emptyList())
AnimatedVisibility(files.isNotEmpty()) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth()
) {
SearchResultList(items = files)
}
}
}

View File

@ -0,0 +1,164 @@
package de.mm20.launcher2.ui.launcher.search.shortcut
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.*
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import kotlin.math.pow
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AppItem(
modifier: Modifier = Modifier,
shortcut: AppShortcut,
onBack: () -> Unit
) {
val viewModel = remember { ShortcutItemVM(shortcut) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier = modifier
) {
Row {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
Text(text = shortcut.label, style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.shortcut_summary, shortcut.appName),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
val badge by viewModel.badge.collectAsState(null)
val iconSize = 84.dp.toPixels().toInt()
val icon by remember(shortcut.key) { viewModel.getIcon(iconSize) }.collectAsState(null)
ShapedLauncherIcon(
size = 84.dp,
modifier = Modifier
.padding(16.dp),
badge = badge,
icon = icon,
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
viewModel.openAppInfo(context as AppCompatActivity)
})
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun ShortcutItemGridPopup(
shortcut: AppShortcut,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
slideInHorizontally(
tween(300),
initialOffsetX = { -it + origin.width.roundToInt() }) with
slideOutHorizontally(
tween(300),
targetOffsetX = { -it + origin.width.roundToInt() }) + fadeOut(snap(400)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
AppItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - 48.dp / 84.dp) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 16.dp * (1 - animationProgress).pow(10),
y = -16.dp * (1 - animationProgress),
),
shortcut = shortcut,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}

View File

@ -0,0 +1,20 @@
package de.mm20.launcher2.ui.launcher.search.shortcut
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.appcompat.app.AppCompatActivity
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.data.AppShortcut
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
class ShortcutItemVM(private val shortcut: AppShortcut) : SearchableItemVM(shortcut) {
fun openAppInfo(context: AppCompatActivity) {
context.tryStartActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${shortcut.launcherShortcut.`package`}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.ui.launcher.search.unitconverter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
import de.mm20.launcher2.ui.search.UnitConverterItem
@Composable
fun ColumnScope.UnitConverterResults() {
val viewModel: SearchVM = viewModel()
val unitConverter by viewModel.unitConverterResult.observeAsState(null)
AnimatedVisibility(unitConverter != null) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
unitConverter?.let { UnitConverterItem(unitConverter = it) }
}
}
}

View File

@ -0,0 +1,156 @@
package de.mm20.launcher2.ui.launcher.search.website
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import de.mm20.launcher2.search.data.Application
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.launcher.search.apps.AppItem
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
@Composable
fun WebsiteItem(
modifier: Modifier = Modifier,
website: Website,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel = remember(website) { WebsiteItemVM(website) }
Column(
modifier = Modifier.fillMaxWidth()
) {
if (website.image.isNotBlank()) {
Image(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
painter = rememberImagePainter(website.image),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = website.label,
style = MaterialTheme.typography.titleLarge
)
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = website.description,
style = MaterialTheme.typography.bodySmall
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
onBack?.invoke()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
onBack?.invoke()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon= Icons.Rounded.Share,
action = {
viewModel.share(context as AppCompatActivity)
}
)
)
Toolbar(
leftActions = if (onBack != null) listOf(
DefaultToolbarAction(
stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack,
action = onBack
)
) else emptyList(),
rightActions = toolbarActions
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun WebsiteItemGridPopup(
website: Website,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(tween(200)) with
fadeOut(tween(200, 200)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
WebsiteItem(
modifier = Modifier
.fillMaxWidth(),
website = website,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}

View File

@ -0,0 +1,32 @@
package de.mm20.launcher2.ui.launcher.search.website
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.search.data.Website
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class WebsiteItemVM(
private val website: Website
): KoinComponent {
private val favoritesRepository: FavoritesRepository by inject()
val isPinned = favoritesRepository.isPinned(website)
fun pin() {
favoritesRepository.pinItem(website)
}
fun unpin() {
favoritesRepository.unpinItem(website)
}
fun share(context: AppCompatActivity) {
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT,
"${website.label}\n\n${website.description}\n\n${website.url}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -0,0 +1,28 @@
package de.mm20.launcher2.ui.launcher.search.website
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
@Composable
fun ColumnScope.WebsiteResults() {
val viewModel: SearchVM = viewModel()
val website by viewModel.websiteResult.observeAsState(null)
AnimatedVisibility(website != null) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
website?.let { WebsiteItem(website = it) }
}
}
}

View File

@ -0,0 +1,161 @@
package de.mm20.launcher2.ui.launcher.search.wikipedia
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import de.mm20.launcher2.search.data.Website
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.launcher.search.website.WebsiteItem
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
@Composable
fun WikipediaItem(
modifier: Modifier = Modifier,
wikipedia: Wikipedia,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
val viewModel = remember(wikipedia) { WikipediaItemVM(wikipedia) }
Column(
modifier = Modifier.fillMaxWidth()
) {
if (!wikipedia.image.isNullOrEmpty()) {
Image(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
painter = rememberImagePainter(wikipedia.image),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = wikipedia.label,
style = MaterialTheme.typography.titleLarge
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = stringResource(id = R.string.wikipedia_source),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary
)
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = wikipedia.text,
style = MaterialTheme.typography.bodySmall
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
onBack?.invoke()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.favorites_menu_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
onBack?.invoke()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
viewModel.share(context as AppCompatActivity)
}
)
)
Toolbar(
leftActions = if (onBack != null) listOf(
DefaultToolbarAction(
stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack,
action = onBack
)
) else emptyList(),
rightActions = toolbarActions
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun WikipediaItemGridPopup(
wikipedia: Wikipedia,
show: Boolean,
animationProgress: Float,
origin: Rect,
onDismiss: () -> Unit
) {
AnimatedContent(
targetState = show,
transitionSpec = {
fadeIn(tween(200)) with
fadeOut(tween(200, 200)) using
SizeTransform { _, _ ->
tween(300)
}
}
) { targetState ->
if (targetState) {
WikipediaItem(
modifier = Modifier
.fillMaxWidth(),
wikipedia = wikipedia,
onBack = onDismiss
)
} else {
Box(
modifier = Modifier
.requiredWidth(origin.width.toDp())
.requiredHeight(origin.height.toDp())
)
}
}
}

View File

@ -0,0 +1,37 @@
package de.mm20.launcher2.ui.launcher.search.wikipedia
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.search.data.Wikipedia
import de.mm20.launcher2.ui.R
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class WikipediaItemVM(
private val wikipedia: Wikipedia
) : KoinComponent {
private val favoritesRepository: FavoritesRepository by inject()
val isPinned = favoritesRepository.isPinned(wikipedia)
fun pin() {
favoritesRepository.pinItem(wikipedia)
}
fun unpin() {
favoritesRepository.unpinItem(wikipedia)
}
fun share(context: AppCompatActivity) {
val text = HtmlCompat.fromHtml(wikipedia.text, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(
Intent.EXTRA_TEXT, "${wikipedia.label}\n\n" +
"${text.substring(0, 200)}\n\n" +
"${wikipedia.wikipediaUrl}/wiki?curid=${wikipedia.id}"
)
shareIntent.type = "text/plain"
context.startActivity(Intent.createChooser(shareIntent, null))
}
}

View File

@ -0,0 +1,28 @@
package de.mm20.launcher2.ui.launcher.search.wikipedia
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.search.SearchVM
@Composable
fun ColumnScope.WikipediaResults() {
val viewModel: SearchVM = viewModel()
val wikipedia by viewModel.wikipediaResult.observeAsState(null)
AnimatedVisibility(wikipedia != null) {
LauncherCard(
modifier = Modifier.padding(bottom = 8.dp)
) {
wikipedia?.let { WikipediaItem(wikipedia = it) }
}
}
}

View File

@ -90,8 +90,6 @@ class WidgetView : LauncherCardView {
enableTransitionType(LayoutTransition.CHANGING)
}
elevation = if (backgroundOpacity < 255) 0f else resources.getDimension(R.dimen.card_elevation)
TooltipCompat.setTooltipText(binding.widgetActionResize, context.getString(R.string.widget_action_adjust_height))
TooltipCompat.setTooltipText(binding.widgetActionRemove, context.getString(R.string.widget_action_remove))
}

View File

@ -135,9 +135,9 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val callView = (View.inflate(context, R.layout.view_list_item, null) as TextView).also {
it.setStartCompoundDrawable(R.drawable.ic_call)
if (contact.phones.size == 1) {
it.text = contact.phones.first()
it.text = contact.phones.first().label
it.setOnClickListener {
call(rootView, contact.phones.first())
call(rootView, contact.phones.first().data)
}
} else {
it.text =
@ -146,10 +146,10 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val menu = PopupMenu(context, it, Gravity.START)
val phones = contact.phones.toList()
for ((i, phone) in phones.withIndex()) {
menu.menu.add(Menu.NONE, i, Menu.NONE, phone)
menu.menu.add(Menu.NONE, i, Menu.NONE, phone.label)
}
menu.setOnMenuItemClickListener {
call(rootView, phones[it.itemId])
call(rootView, phones[it.itemId].data)
true
}
menu.show()
@ -164,9 +164,9 @@ class ContactDetailRepresentation : Representation, KoinComponent {
(View.inflate(context, R.layout.view_list_item, null) as TextView).also {
it.setStartCompoundDrawable(R.drawable.ic_message)
if (contact.phones.size == 1) {
it.text = contact.phones.first()
it.text = contact.phones.first().label
it.setOnClickListener {
message(rootView, contact.phones.first())
message(rootView, contact.phones.first().data)
}
} else {
it.text = context.getString(
@ -177,10 +177,10 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val menu = PopupMenu(context, it, Gravity.START)
val phones = contact.phones.toList()
for ((i, phone) in phones.withIndex()) {
menu.menu.add(Menu.NONE, i, Menu.NONE, phone)
menu.menu.add(Menu.NONE, i, Menu.NONE, phone.label)
}
menu.setOnMenuItemClickListener {
message(rootView, phones[it.itemId])
message(rootView, phones[it.itemId].data)
true
}
menu.show()
@ -195,9 +195,9 @@ class ContactDetailRepresentation : Representation, KoinComponent {
(View.inflate(context, R.layout.view_list_item, null) as TextView).also {
it.setStartCompoundDrawable(R.drawable.ic_mail)
if (contact.emails.size == 1) {
it.text = contact.emails.first()
it.text = contact.emails.first().label
it.setOnClickListener {
email(rootView, contact.emails.first())
email(rootView, contact.emails.first().data)
}
} else {
it.text =
@ -206,10 +206,10 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val menu = PopupMenu(context, it, Gravity.START)
val emails = contact.emails.toList()
for ((i, email) in emails.withIndex()) {
menu.menu.add(Menu.NONE, i, Menu.NONE, email)
menu.menu.add(Menu.NONE, i, Menu.NONE, email.label)
}
menu.setOnMenuItemClickListener {
email(rootView, emails[it.itemId])
email(rootView, emails[it.itemId].data)
true
}
menu.show()
@ -224,9 +224,9 @@ class ContactDetailRepresentation : Representation, KoinComponent {
(View.inflate(context, R.layout.view_list_item, null) as TextView).also {
it.setStartCompoundDrawable(R.drawable.ic_telegram)
if (contact.telegram.size == 1) {
it.text = contact.telegram.first().substringAfter('$')
it.text = contact.telegram.first().label
it.setOnClickListener {
telegram(rootView, contact.telegram.first().substringBefore('$'))
telegram(rootView, contact.telegram.first().data)
}
} else {
it.text = context.getString(
@ -237,10 +237,10 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val menu = PopupMenu(context, it, Gravity.START)
val phones = contact.telegram.toList()
for ((i, phone) in phones.withIndex()) {
menu.menu.add(Menu.NONE, i, Menu.NONE, phone.substringAfter('$'))
menu.menu.add(Menu.NONE, i, Menu.NONE, phone.label)
}
menu.setOnMenuItemClickListener {
telegram(rootView, phones[it.itemId].substringBefore('$'))
telegram(rootView, phones[it.itemId].data)
true
}
menu.show()
@ -254,9 +254,9 @@ class ContactDetailRepresentation : Representation, KoinComponent {
(View.inflate(context, R.layout.view_list_item, null) as TextView).also {
it.setStartCompoundDrawable(R.drawable.ic_whatsapp)
if (contact.whatsapp.size == 1) {
it.text = contact.whatsapp.first().substringAfter("$")
it.text = contact.whatsapp.first().label
it.setOnClickListener {
whatsapp(rootView, contact.whatsapp.first().substringBefore("$"))
whatsapp(rootView, contact.whatsapp.first().data)
}
} else {
it.text = context.getString(
@ -267,10 +267,10 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val menu = PopupMenu(context, it, Gravity.START)
val phones = contact.whatsapp.toList()
for ((i, phone) in phones.withIndex()) {
menu.menu.add(Menu.NONE, i, Menu.NONE, phone.substringAfter('$'))
menu.menu.add(Menu.NONE, i, Menu.NONE, phone.label)
}
menu.setOnMenuItemClickListener {
whatsapp(rootView, phones[it.itemId].substringBefore('$'))
whatsapp(rootView, phones[it.itemId].data)
true
}
menu.show()
@ -284,9 +284,9 @@ class ContactDetailRepresentation : Representation, KoinComponent {
(View.inflate(context, R.layout.view_list_item, null) as TextView).also {
it.setStartCompoundDrawable(R.drawable.ic_location)
if (contact.postals.size == 1) {
it.text = contact.postals.first()
it.text = contact.postals.first().label
it.setOnClickListener {
navigate(rootView, contact.postals.first())
navigate(rootView, contact.postals.first().data)
}
} else {
it.text = context.getString(
@ -297,10 +297,10 @@ class ContactDetailRepresentation : Representation, KoinComponent {
val menu = PopupMenu(context, it, Gravity.START)
val postals = contact.postals.toList()
for ((i, postal) in postals.withIndex()) {
menu.menu.add(Menu.NONE, i, Menu.NONE, postal)
menu.menu.add(Menu.NONE, i, Menu.NONE, postal.label)
}
menu.setOnMenuItemClickListener {
navigate(rootView, postals[it.itemId])
navigate(rootView, postals[it.itemId].data)
true
}
menu.show()
@ -311,44 +311,44 @@ class ContactDetailRepresentation : Representation, KoinComponent {
}
}
private fun call(rootView: SearchableView, number: String) {
private fun call(rootView: SearchableView, data: String) {
val context = rootView.context
try {
val callIntent = Intent(Intent.ACTION_DIAL)
callIntent.data = Uri.parse("tel:$number")
callIntent.data = Uri.parse(data)
ActivityStarter.start(context, rootView, intent = callIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.activity_not_found, Toast.LENGTH_SHORT).show()
}
}
private fun message(rootView: SearchableView, number: String) {
private fun message(rootView: SearchableView, data: String) {
val context = rootView.context
try {
val messageIntent = Intent(Intent.ACTION_VIEW)
messageIntent.data = Uri.parse("sms:$number")
messageIntent.data = Uri.parse(data)
ActivityStarter.start(context, rootView, intent = messageIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.activity_not_found, Toast.LENGTH_SHORT).show()
}
}
private fun email(rootView: SearchableView, address: String) {
private fun email(rootView: SearchableView, data: String) {
val context = rootView.context
try {
val mailIntent = Intent(Intent.ACTION_VIEW)
mailIntent.data = Uri.parse("mailto:$address")
mailIntent.data = Uri.parse(data)
ActivityStarter.start(context, rootView, intent = mailIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.activity_not_found, Toast.LENGTH_SHORT).show()
}
}
private fun whatsapp(rootView: SearchableView, number: String) {
private fun whatsapp(rootView: SearchableView, data: String) {
val context = rootView.context
try {
val whatsappIntent = Intent(Intent.ACTION_VIEW)
whatsappIntent.data = Uri.withAppendedPath(ContactsContract.Data.CONTENT_URI, number)
whatsappIntent.data = Uri.parse(data)
ActivityStarter.start(context, rootView, intent = whatsappIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.activity_not_found, Toast.LENGTH_SHORT).show()

View File

@ -162,21 +162,19 @@ class FileDetailRepresentation : Representation, KoinComponent {
sb.append(
context.getString(
R.string.file_meta_data_entry,
context.getString(R.string.file_meta_type),
R.string.file_meta_type,
file.mimeType
)
)
for ((k, v) in file.metaData) {
sb.append("\n")
.append(context.getString(R.string.file_meta_data_entry, context.getString(k), v))
.append(context.getString(k, v))
}
if (!file.isDirectory) {
sb.append("\n").append(
context.getString(
R.string.file_meta_data_entry,
context.getString(R.string.file_meta_size),
R.string.file_meta_size,
formatFileSize(file.size)
)
)
@ -184,8 +182,7 @@ class FileDetailRepresentation : Representation, KoinComponent {
if (file.path.isNotEmpty()) {
sb.append("\n").append(
context.getString(
R.string.file_meta_data_entry,
context.getString(R.string.file_meta_path),
R.string.file_meta_path,
file.path
)
)

View File

@ -50,6 +50,8 @@ open class LauncherCardView @JvmOverloads constructor(
private var overrideBackgroundOpacity = false
private var actualElevation: Float
init {
strokeColor = cardBackgroundColor.defaultColor
strokeWidth = (currentCardStyle.borderWidth * dp).roundToInt()
@ -71,8 +73,8 @@ open class LauncherCardView @JvmOverloads constructor(
}
}
strokeOpacity = if (backgroundOpacity == 0) 0 else 0xFF
elevation = if (backgroundOpacity == 255) elevation else 0f
cardElevation = elevation
actualElevation = context.resources.getDimension(R.dimen.card_elevation)
cardElevation = if (backgroundOpacity == 255) actualElevation else 0f
}
private val dataStore: LauncherDataStore by inject()
@ -88,8 +90,7 @@ open class LauncherCardView @JvmOverloads constructor(
if (!overrideBackgroundOpacity) {
backgroundOpacity = (it.opacity * 255).roundToInt()
strokeOpacity = if (backgroundOpacity == 0) 0 else 0xFF
elevation = if (backgroundOpacity == 255) elevation else 0f
cardElevation = elevation
cardElevation = if (backgroundOpacity == 255) actualElevation else 0f
}
strokeWidth = (it.borderWidth * dp).roundToInt()
radius = it.radius * dp

View File

@ -12,4 +12,12 @@ val LocalAppWidgetHost = compositionLocalOf<AppWidgetHost?>(defaultFactory = { n
val LocalNavController = compositionLocalOf<NavController?> { null }
val LocalCardStyle = compositionLocalOf<Settings.CardSettings> { Settings.CardSettings.getDefaultInstance() }
val LocalCardStyle = compositionLocalOf<Settings.CardSettings> { Settings.CardSettings.getDefaultInstance() }
val LocalFavoritesEnabled = compositionLocalOf { true }
/**
* Workaround a bug in Jetpack Compose which incorrectly places popups
* that are nested inside other popups.
*/
val LocalWindowPosition = compositionLocalOf { 0f }

View File

@ -0,0 +1,24 @@
package de.mm20.launcher2.ui.modifier
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
@Stable
fun Modifier.scale(
scaleX: Float, scaleY: Float, transformOrigin: TransformOrigin
) = if (scaleX != 1.0f || scaleY != 1.0f) {
graphicsLayer(
scaleX = scaleX,
scaleY = scaleY,
transformOrigin = transformOrigin
)
} else {
this
}
fun Modifier.scale(
scale: Float,
transformOrigin: TransformOrigin
) = scale(scale, scale, transformOrigin)

View File

@ -19,16 +19,15 @@
android:id="@+id/scrollContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="8dp">
android:orientation="vertical">
<de.mm20.launcher2.ui.launcher.search.SearchView
android:id="@+id/searchContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
@ -38,7 +37,8 @@
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical" />
android:orientation="vertical"
android:layout_margin="8dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>