diff --git a/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt b/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt index 02511239..e69d70d2 100644 --- a/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt +++ b/badges/src/main/java/de/mm20/launcher2/badges/BadgeRepository.kt @@ -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 = 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) } } } diff --git a/base/src/main/res/values-v31/color-schemes.xml b/base/src/main/res/values-v31/color-schemes.xml index 5b719179..1da17daf 100644 --- a/base/src/main/res/values-v31/color-schemes.xml +++ b/base/src/main/res/values-v31/color-schemes.xml @@ -26,6 +26,6 @@ @android:color/system_neutral2_700 @android:color/system_neutral2_800 @android:color/system_neutral2_50 - false + true \ No newline at end of file diff --git a/base/src/main/res/values/color-schemes.xml b/base/src/main/res/values/color-schemes.xml index e6780d31..61d255d5 100644 --- a/base/src/main/res/values/color-schemes.xml +++ b/base/src/main/res/values/color-schemes.xml @@ -56,6 +56,6 @@ @android:color/black @android:color/black @android:color/white - false + true \ No newline at end of file diff --git a/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt b/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt index 291a80cc..31adbd31 100644 --- a/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt +++ b/calendar/src/main/java/de/mm20/launcher2/search/data/CalendarEvent.kt @@ -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 { diff --git a/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt b/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt index 22013dbd..f0ab75b4 100644 --- a/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt +++ b/contacts/src/main/java/de/mm20/launcher2/search/data/Contact.kt @@ -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, - val emails: Set, - val telegram: Set, - val whatsapp: Set, - val postals: Set + val phones: Set, + val emails: Set, + val telegram: Set, + val whatsapp: Set, + val postals: Set, ) : 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): 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() - val emails = mutableSetOf() - val telegram = mutableSetOf() - val whatsapp = mutableSetOf() - val postals = mutableSetOf() + val phones = mutableSetOf() + val emails = mutableSetOf() + val telegram = mutableSetOf() + val whatsapp = mutableSetOf() + val postals = mutableSetOf() 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( } } -} \ No newline at end of file +} + +data class ContactInfo( + val label: String, + val data: String +) \ No newline at end of file diff --git a/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt b/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt index 70bf286e..c2cf2b06 100644 --- a/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt +++ b/files/src/main/java/de/mm20/launcher2/files/FilesRepository.kt @@ -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> @@ -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() + for (provider in providers) { + results.addAll(provider.search(query)) + send(results) } } } diff --git a/i18n/src/main/res/values-de/strings.xml b/i18n/src/main/res/values-de/strings.xml index 90d03107..a4078e34 100644 --- a/i18n/src/main/res/values-de/strings.xml +++ b/i18n/src/main/res/values-de/strings.xml @@ -8,6 +8,7 @@ Deinstallieren Teilen Version %1$s\n%2$s + Version %1$s Einstellungen Hintergrundbild Einstellungen @@ -74,22 +75,22 @@ Text-Datei Das Verzeichnis %1$s wird samt Inhalt unwideruflich gelöscht. Fortfahren? Die Datei %1$s wird unwideruflich gelöscht. Fortfahren? - Titel - Künstler - Album - Länge - Jahr - %1$s: %2$s - Größe - Pfad - Typ - Abmessungen - App-Name - Version - Paketname - Min-SDK-Version + Titel: %1$s + Künstler: %1$s + Album: %1$s + Länge: %1$s + Jahr: %1$s + Größe: %1$s + Pfad: %1$s + Typ: %1$s + Abmessungen: %1$s + App-Name: %1$s + Version: %1$s + Paketname: %1$s + Min-SDK-Version: %1$s + Ort: %1$s + Eigentümer: %1$s Dienste - Ort Google YouTube Google Play @@ -124,11 +125,12 @@ %1$d E-Mail-Adressen %1$d Postadressen App-Info + Starten + Öffnen Verbundene Accounts und Dienste verwalten Google Angemeldet als %1$s Abmelden - Eigentümer Zeichnung E-Book Formular diff --git a/i18n/src/main/res/values-fr/strings.xml b/i18n/src/main/res/values-fr/strings.xml index 6178d667..b8f6ac5c 100644 --- a/i18n/src/main/res/values-fr/strings.xml +++ b/i18n/src/main/res/values-fr/strings.xml @@ -108,22 +108,22 @@ Fichier texte Le dossier %1$s et ce qu\'il contient vont être définitivement supprimés. Procéder ? Le fichier %1$s va être définitivement supprimé. Procéder ? - Titre - Album - Durée - Année - %1$s: %2$s - Taille - Chemin d\'accès - Type - Dimensions - Nom de l\'application - Version - Nom du paquet - Version min du SDK + Titre: %1$s + Album: %1$s + Durée: %1$s + Année: %1$s + Taille: %1$s + Chemin d\'accès: %1$s + Type: %1$s + Dimensions: %1$s + Nom de l\'application: %1$s + Version: %1$s + Nom du paquet: %1$s + Version min du SDK: %1$s + Position: %1$s + Propriétaire: %1$s Services Suivre le système - Position Google YouTube Google Play @@ -166,7 +166,6 @@ Google Connecté en tant que %1$s Se déconnecter - Propriétaire Vous n\'êtes pas connecté E-book Dessin diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 586f243d..6027d8ec 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Share Version %1$s\n%2$s + Version %1$s Settings @@ -108,23 +109,23 @@ Text file The directory %1$s and all its content will be deleted permanently. Proceed? The file %1$s will be deleted permanently. Proceed? - Title - Artist - Album - Duration - Year - %1$s: %2$s - Size - Path - Type - Dimensions - App name - Version - Package name - Min SDK version + Title: %1$s + Artist: %1$s + Album: %1$s + Duration: %1$s + Year: %1$s + Size: %1$s + Path: %1$s + Type: %1$s + Dimensions: %1$s + App name: %1$s + Version: %1$s + Package name: %1$s + Min SDK version: %1$s + Owner: %1$s + Location: %1$s Services Follow system - Location Google YouTube Google Play @@ -163,11 +164,12 @@ %1$d email addresses %1$d postal addresses App info + Launch + Open Manage connected accounts and services Google Signed in as %1$s Log out - Owner You are currently not logged in E-book Drawing diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index 1ab54c37..e978c718 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -8,8 +8,8 @@ @@ -32,9 +32,9 @@ { + 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 +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/animation/TextUnit.kt b/ui/src/main/java/de/mm20/launcher2/ui/animation/TextUnit.kt new file mode 100644 index 00000000..8219374d --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/animation/TextUnit.kt @@ -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 { + @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 Transition.animateTextUnit( + noinline transitionSpec: + @Composable Transition.Segment.() -> FiniteAnimationSpec = { spring() }, + label: String = "ValueAnimation", + targetValueByState: @Composable (state: S) -> TextUnit +): State { + return animateValue( + typeConverter = TextUnitConverter, + label = label, + transitionSpec = transitionSpec, + targetValueByState = targetValueByState + ) +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/Chip.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/Chip.kt new file mode 100644 index 00000000..d6046f71 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/Chip.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/InnerCard.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/InnerCard.kt new file mode 100644 index 00000000..b18ca72a --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/InnerCard.kt @@ -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 + ) +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt new file mode 100644 index 00000000..2801dee5 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt @@ -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 { 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()) + } + } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/Toolbar.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/Toolbar.kt index 1b59236a..a60799d6 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/Toolbar.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/Toolbar.kt @@ -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, 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, 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 + } } } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt b/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt index c4bc5f8c..6cd68dd7 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/icons/Icons.kt @@ -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() + } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt index cc2bff95..2a5d5275 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt @@ -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 diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt index 2df3b95e..fb382fc4 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt @@ -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) } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt new file mode 100644 index 00000000..5d1b543c --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt @@ -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() + + 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()) + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItemVM.kt new file mode 100644 index 00000000..0f5603ea --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItemVM.kt @@ -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() ?: return null + return launcherApps.getShortcutIconDrawable(shortcut, 0) + } + + fun isShortcutPinned(shortcut: AppShortcut): Flow { + 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) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt new file mode 100644 index 00000000..7e4ce028 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppResults.kt @@ -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) + } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calculator/CalculatorResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calculator/CalculatorResults.kt new file mode 100644 index 00000000..399dcc43 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calculator/CalculatorResults.kt @@ -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) } + } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarItem.kt new file mode 100644 index 00000000..098cb1a5 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarItem.kt @@ -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() + + 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()) + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarItemVM.kt new file mode 100644 index 00000000..a42163dd --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarItemVM.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarResults.kt new file mode 100644 index 00000000..1074da07 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/calendar/CalendarResults.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt new file mode 100644 index 00000000..20fa6057 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt @@ -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 { + 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 + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt new file mode 100644 index 00000000..fa737ea0 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItem.kt @@ -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 + } + ) + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItemVM.kt new file mode 100644 index 00000000..06fdd8ee --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridItemVM.kt @@ -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) \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/SearchResultGrid.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/SearchResultGrid.kt new file mode 100644 index 00000000..05f146cd --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/SearchResultGrid.kt @@ -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) { + 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)) + } + } + } + } + } +} diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt new file mode 100644 index 00000000..2a2dc50f --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItem.kt @@ -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 } + ) + } + } + } + + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItemVM.kt new file mode 100644 index 00000000..d0c5a09e --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListItemVM.kt @@ -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) { +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt new file mode 100644 index 00000000..0b9daa42 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/SearchResultList.kt @@ -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) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + for (item in items) { + key(item.key) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + item = item + ) + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt new file mode 100644 index 00000000..d27751ff --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt @@ -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() + + 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()) + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItemVM.kt new file mode 100644 index 00000000..66b4701d --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItemVM.kt @@ -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)) + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactResults.kt new file mode 100644 index 00000000..343d4f48 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactResults.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt new file mode 100644 index 00000000..8348687c --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/FavoritesResults.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileItem.kt new file mode 100644 index 00000000..cf22da45 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileItem.kt @@ -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() + + 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" + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileItemVM.kt new file mode 100644 index 00000000..7fe7ebf0 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileItemVM.kt @@ -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)) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileResults.kt new file mode 100644 index 00000000..e206109c --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/files/FileResults.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutItem.kt new file mode 100644 index 00000000..473b9a32 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutItem.kt @@ -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() + + 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()) + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutItemVM.kt new file mode 100644 index 00000000..39288394 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/shortcut/ShortcutItemVM.kt @@ -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) + } + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterResults.kt new file mode 100644 index 00000000..4e7bd269 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/unitconverter/UnitConverterResults.kt @@ -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) } + } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteItem.kt new file mode 100644 index 00000000..b5d97399 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteItem.kt @@ -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() + + 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()) + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteItemVM.kt new file mode 100644 index 00000000..55843971 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteItemVM.kt @@ -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)) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteResults.kt new file mode 100644 index 00000000..f3cfe58c --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/website/WebsiteResults.kt @@ -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) } + } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaItem.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaItem.kt new file mode 100644 index 00000000..12e6f3d9 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaItem.kt @@ -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() + + 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()) + ) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaItemVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaItemVM.kt new file mode 100644 index 00000000..e09d4536 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaItemVM.kt @@ -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)) + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaResults.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaResults.kt new file mode 100644 index 00000000..8a29345b --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/wikipedia/WikipediaResults.kt @@ -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) } + } + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/component/WidgetView.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/component/WidgetView.kt index aaab89e5..1bfd89c5 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/component/WidgetView.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/component/WidgetView.kt @@ -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)) } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt index 8ff15930..96e984c8 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/ContactDetailRepresentation.kt @@ -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() diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt index ed5f239b..2627cbfe 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/search/FileDetailRepresentation.kt @@ -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 ) ) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherCardView.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherCardView.kt index 68bdb245..6d4a79cd 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherCardView.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/view/LauncherCardView.kt @@ -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 diff --git a/ui/src/main/java/de/mm20/launcher2/ui/locals/CompositionLocals.kt b/ui/src/main/java/de/mm20/launcher2/ui/locals/CompositionLocals.kt index 2beb8dbf..d13c5650 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/locals/CompositionLocals.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/locals/CompositionLocals.kt @@ -12,4 +12,12 @@ val LocalAppWidgetHost = compositionLocalOf(defaultFactory = { n val LocalNavController = compositionLocalOf { null } -val LocalCardStyle = compositionLocalOf { Settings.CardSettings.getDefaultInstance() } \ No newline at end of file +val LocalCardStyle = compositionLocalOf { 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 } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/modifier/Modifiers.kt b/ui/src/main/java/de/mm20/launcher2/ui/modifier/Modifiers.kt new file mode 100644 index 00000000..f080753d --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/modifier/Modifiers.kt @@ -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) \ No newline at end of file diff --git a/ui/src/main/res/layout/view_launcher_scaffold.xml b/ui/src/main/res/layout/view_launcher_scaffold.xml index f504131b..8323f28e 100644 --- a/ui/src/main/res/layout/view_launcher_scaffold.xml +++ b/ui/src/main/res/layout/view_launcher_scaffold.xml @@ -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"> @@ -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" />