Further search result design adjustments

This commit is contained in:
MM20 2024-06-29 21:57:31 +02:00
parent 7258d456d3
commit 304ca12e65
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
18 changed files with 775 additions and 336 deletions

View File

@ -2,6 +2,7 @@ package de.mm20.launcher2.ui.launcher.search.apps
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
@ -9,64 +10,62 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.expandIn import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.NavigateNext
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.OpenInNew import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.HorizontalDivider
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.Application
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -80,8 +79,6 @@ import de.mm20.launcher2.ui.launcher.search.listItemViewModel
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -98,12 +95,10 @@ fun AppItem(
} }
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
modifier = modifier modifier = modifier.verticalScroll(rememberScrollState())
) { ) {
Row { Row {
Column( Column(
@ -144,143 +139,215 @@ fun AppItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
FlowRow(
modifier = Modifier
.padding(top = 12.dp)
.animateContentSize(),
mainAxisSpacing = 12.dp,
crossAxisSpacing = 0.dp
) {
val notifications by viewModel.notifications.collectAsState(emptyList())
for (not in notifications) {
val title = not.title?.takeIf { it.isNotBlank() }
?: not.text?.takeIf { it.isNotBlank() }
?: continue
val icon = remember(not.smallIcon) { not.smallIcon?.loadDrawable(context) }
InputChip(
modifier = Modifier.width(IntrinsicSize.Max),
selected = false,
label = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
},
avatar = {
Box(modifier = Modifier.background(Color(not.color))) {
AsyncImage(
modifier = Modifier
.requiredSize(InputChipDefaults.AvatarSize)
.padding(3.dp),
model = icon,
contentDescription = null
)
}
},
trailingIcon = if (not.isClearable) {
{
Icon(
Icons.Rounded.Clear,
null,
modifier = Modifier
.clip(CircleShape)
.size(InputChipDefaults.IconSize)
.clickable {
viewModel.clearNotification(not)
},
)
}
} else null,
onClick = {
try {
not.contentIntent?.send()
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}
)
}
val shortcuts by viewModel.shortcuts.collectAsState(emptyList())
for (shortcut in shortcuts) {
val title =
shortcut.labelOverride ?: shortcut.label
val isPinned by remember(shortcut) { viewModel.isShortcutPinned(shortcut) }.collectAsState(
false
)
val iconSizePx = InputChipDefaults.AvatarSize.toPixels()
val icon by
remember {
viewModel.getShortcutIcon(
context,
shortcut,
iconSizePx.toInt()
)
}.collectAsState(null)
InputChip(
modifier = Modifier.width(IntrinsicSize.Max),
selected = false,
onClick = {
viewModel.launchShortcut(context, shortcut)
},
label = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
},
avatar = {
ShapedLauncherIcon(
size = InputChipDefaults.AvatarSize,
icon = { icon },
shape = CircleShape,
)
},
trailingIcon = if (LocalFavoritesEnabled.current) {
{
Icon(
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline,
null,
modifier = Modifier
.clip(CircleShape)
.requiredSize(InputChipDefaults.IconSize)
.clickable {
if (isPinned) {
viewModel.unpinShortcut(shortcut)
} else {
viewModel.pinShortcut(shortcut)
}
},
)
}
} else null
)
}
}
} }
val badge by viewModel.badge.collectAsStateWithLifecycle(null) val badge by viewModel.badge.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle() val icon by viewModel.icon.collectAsStateWithLifecycle()
ShapedLauncherIcon( ShapedLauncherIcon(
size = 84.dp, size = 48.dp,
modifier = Modifier modifier = Modifier
.padding(16.dp), .padding(16.dp),
badge = { badge }, badge = { badge },
icon = { icon }, icon = { icon },
) )
} }
val notifications by viewModel.notifications.collectAsState(emptyList())
AnimatedVisibility(notifications.isNotEmpty()) {
var showAllNotifications by remember { mutableStateOf(false) }
AnimatedContent(
showAllNotifications || notifications.size == 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small)
) { showAll ->
if (showAll) {
Column(
modifier = Modifier.animateContentSize()
) {
for ((i, not) in notifications.withIndex()) {
val icon =
remember(not.smallIcon) { not.smallIcon?.loadDrawable(context) }
if (not.title == null && not.text == null) continue
if (i > 0) {
HorizontalDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
try {
not.contentIntent?.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}
.padding(vertical = 4.dp)
) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.clip(CircleShape)
.background(Color(not.color))
.size(32.dp)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = icon,
contentDescription = null
)
}
Column(
modifier = Modifier.weight(1f)
) {
if (not.title != null) {
Text(
not.title!!,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (not.text != null) {
Text(
not.text!!,
modifier = Modifier.padding(top = 2.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (not.isClearable) {
IconButton(
onClick = {
viewModel.clearNotification(not)
}
) {
Icon(Icons.Rounded.Clear, null)
}
}
}
}
}
} else {
Row(
modifier = Modifier
.clickable {
showAllNotifications = true
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Rounded.Notifications,
null,
modifier = Modifier.padding(horizontal = 16.dp)
)
Text(
pluralStringResource(
R.plurals.app_info_notifications,
notifications.size,
notifications.size
),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
Icons.AutoMirrored.Rounded.NavigateNext,
null,
modifier = Modifier.padding(horizontal = 12.dp)
)
}
}
}
}
val shortcuts by viewModel.children.collectAsState(emptyList())
if (shortcuts.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp)
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small)
) {
for ((i, shortcut) in shortcuts.withIndex()) {
val isPinned by remember(shortcut) { viewModel.isChildPinned(shortcut) }.collectAsState(
false
)
val iconSizePx = 32.dp.toPixels()
val icon by
remember {
viewModel.getChildIcon(
shortcut,
iconSizePx.toInt()
)
}.collectAsState(null)
if (i > 0) {
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
viewModel.launchChild(context, shortcut)
}
.padding(vertical = 4.dp)
) {
ShapedLauncherIcon(
size = 32.dp,
icon = { icon },
shape = CircleShape,
modifier = Modifier
.padding(horizontal = 12.dp)
.size(32.dp),
)
Text(
shortcut.labelOverride ?: shortcut.label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
IconButton(
onClick = {
if (isPinned) {
viewModel.unpinChild(shortcut)
} else {
viewModel.pinChild(shortcut)
}
}
) {
Icon(
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline,
null
)
}
}
}
}
}
}
val toolbarActions = mutableListOf<ToolbarAction>() val toolbarActions = mutableListOf<ToolbarAction>()
@ -316,7 +383,7 @@ fun AppItem(
toolbarActions.add( toolbarActions.add(
DefaultToolbarAction( DefaultToolbarAction(
label = stringResource(R.string.menu_launch), label = stringResource(R.string.menu_launch),
icon = Icons.Rounded.OpenInNew, icon = Icons.AutoMirrored.Rounded.OpenInNew,
action = { action = {
viewModel.launch(context) viewModel.launch(context)
} }
@ -384,7 +451,7 @@ fun AppItem(
leftActions = listOf( leftActions = listOf(
DefaultToolbarAction( DefaultToolbarAction(
label = stringResource(id = R.string.menu_back), label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack icon = Icons.AutoMirrored.Rounded.ArrowBack
) { ) {
onBack() onBack()
} }
@ -416,10 +483,6 @@ fun AppItemGridPopup(
AppItem( AppItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset( .offset(
x = lerp(16.dp, 0.dp, animationProgress), x = lerp(16.dp, 0.dp, animationProgress),
y = lerp(-16.dp, 0.dp, animationProgress) y = lerp(-16.dp, 0.dp, animationProgress)

View File

@ -6,7 +6,7 @@ import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.customview.view.AbsSavedState import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.appshortcuts.AppShortcutRepository import de.mm20.launcher2.appshortcuts.AppShortcutRepository
import de.mm20.launcher2.badges.BadgeService import de.mm20.launcher2.badges.BadgeService
import de.mm20.launcher2.devicepose.DevicePoseProvider import de.mm20.launcher2.devicepose.DevicePoseProvider
@ -20,7 +20,6 @@ import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.File import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.UpdatableSearchable import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult import de.mm20.launcher2.search.UpdateResult
@ -36,12 +35,12 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -51,6 +50,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
private val iconService: IconService by inject() private val iconService: IconService by inject()
private val tagsService: TagsService by inject() private val tagsService: TagsService by inject()
private val notificationRepository: NotificationRepository by inject() private val notificationRepository: NotificationRepository by inject()
private val appRepository: AppRepository by inject()
private val appShortcutRepository: AppShortcutRepository by inject() private val appShortcutRepository: AppShortcutRepository by inject()
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
private val locationSearchSettings: LocationSearchSettings by inject() private val locationSearchSettings: LocationSearchSettings by inject()
@ -95,15 +95,30 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
else notificationRepository.notifications.map { it.filter { it.packageName == searchable.componentName.packageName && !it.isGroupSummary } } else notificationRepository.notifications.map { it.filter { it.packageName == searchable.componentName.packageName && !it.isGroupSummary } }
} }
val shortcuts = searchable.map { val children = searchable.flatMapLatest {
if (it !is Application) emptyList() when(it) {
else appShortcutRepository is Application -> appShortcutRepository
.findMany( .findMany(
componentName = it.componentName, componentName = it.componentName,
user = it.user, user = it.user,
manifest = true, manifest = true,
dynamic = true, dynamic = true,
).first() )
is AppShortcut -> {
val packageName = it.componentName?.packageName ?: return@flatMapLatest flowOf(
emptyList()
)
appRepository
.findOne(
packageName = packageName,
user = it.user,
)
.map { listOfNotNull(it) }
}
else -> flowOf(
emptyList()
)
}
} }
fun launch(context: Context, bounds: Rect? = null): Boolean { fun launch(context: Context, bounds: Rect? = null): Boolean {
@ -134,24 +149,26 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
notificationRepository.cancelNotification(notification) notificationRepository.cancelNotification(notification)
} }
fun getShortcutIcon(context: Context, shortcut: AppShortcut, size: Int): Flow<LauncherIcon?> { fun getChildIcon(child: SavableSearchable, size: Int): Flow<LauncherIcon?> {
return iconService.getIcon(shortcut, size) return iconService.getIcon(child, size)
} }
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> { fun isChildPinned(child: SavableSearchable): Flow<Boolean> {
return favoritesService.isPinned(shortcut) return favoritesService.isPinned(child)
} }
fun pinShortcut(shortcut: AppShortcut) { fun pinChild(child: SavableSearchable) {
favoritesService.pinItem(shortcut) favoritesService.pinItem(child)
} }
fun unpinShortcut(shortcut: AppShortcut) { fun unpinChild(child: SavableSearchable) {
favoritesService.unpinItem(shortcut) favoritesService.unpinItem(child)
} }
fun launchShortcut(context: Context, shortcut: AppShortcut) { fun launchChild(context: Context, child: SavableSearchable) {
shortcut.launch(context, null) if(child.launch(context, null)) {
favoritesService.reportLaunch(child)
}
} }
fun delete(context: Context) { fun delete(context: Context) {

View File

@ -193,6 +193,8 @@ fun ListItem(
}, },
onLongClick = { onShowDetails(true) }), onLongClick = { onShowDetails(true) }),
website = item, website = item,
onBack = { onShowDetails(false) },
showDetails = showDetails,
) )
} }
} }

View File

@ -5,33 +5,35 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandIn import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -43,20 +45,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.ui.R 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.DefaultToolbarAction
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.ShapedLauncherIcon import de.mm20.launcher2.ui.component.ShapedLauncherIcon
@ -68,9 +68,7 @@ import de.mm20.launcher2.ui.launcher.search.listItemViewModel
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled
import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.locals.LocalSnackbarHostState
import de.mm20.launcher2.ui.modifier.scale import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.launch
import kotlin.math.pow import kotlin.math.pow
@Composable @Composable
@ -89,14 +87,243 @@ fun AppShortcutItem(
viewModel.init(shortcut, iconSize.toInt()) viewModel.init(shortcut, iconSize.toInt())
} }
val lifecycleOwner = LocalLifecycleOwner.current val badge by viewModel.badge.collectAsState(null)
val snackbarHostState = LocalSnackbarHostState.current val icon by viewModel.icon.collectAsStateWithLifecycle()
var requestDelete by remember { mutableStateOf(false) } var requestDelete by remember { mutableStateOf(false) }
val transition = updateTransition(showDetails, label = "AppShortcutItem") SharedTransitionLayout(
modifier = modifier,
) {
AnimatedContent(showDetails) { showDetails ->
if (showDetails) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = 16.dp,
start = 16.dp,
end = 16.dp,
bottom = 8.dp
),
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
modifier = Modifier.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent,
),
text = shortcut.labelOverride ?: shortcut.label,
style = MaterialTheme.typography.titleMedium,
)
Column( val children by viewModel.children.collectAsState(emptyList())
for (app in children) {
val title =
app.labelOverride ?: app.label
val isPinned by remember(app) { viewModel.isChildPinned(app) }.collectAsState(
false
)
val iconSizePx = InputChipDefaults.AvatarSize.toPixels()
val childIcon by
remember {
viewModel.getChildIcon(
app,
iconSizePx.toInt()
)
}.collectAsState(null)
InputChip(
modifier = Modifier.width(IntrinsicSize.Max).padding(top = 8.dp),
selected = false,
onClick = {
viewModel.launchChild(context, app)
},
label = {
Text(
title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
},
avatar = {
ShapedLauncherIcon(
size = InputChipDefaults.AvatarSize,
icon = { childIcon },
shape = CircleShape,
)
},
trailingIcon = if (LocalFavoritesEnabled.current) {
{
Icon(
if (isPinned) Icons.Rounded.Star else Icons.Rounded.StarOutline,
null,
modifier = Modifier
.clip(CircleShape)
.requiredSize(InputChipDefaults.IconSize)
.clickable {
if (isPinned) {
viewModel.unpinChild(app)
} else {
viewModel.pinChild(app)
}
},
)
}
} else null
)
}
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.sharedElement(
rememberSharedContentState("icon"),
this@AnimatedContent,
),
badge = { badge },
icon = { icon },
)
}
if(shortcut.isUnavailable) {
MissingPermissionBanner(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
text = stringResource(R.string.shortcut_unavailable_description, stringResource(R.string.app_name)),
onClick = {
viewModel.requestShortcutPermission(context as AppCompatActivity)
}
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
val packageName = shortcut.packageName
if (packageName != null) {
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_app_info),
icon = Icons.Rounded.Info
) {
context.tryStartActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
})
}
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Tune,
action = { sheetManager.showCustomizeSearchableModal(shortcut) }
))
if (shortcut.canDelete) {
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_delete),
icon = Icons.Rounded.Delete,
action = { requestDelete = true }
))
}
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.AutoMirrored.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
top = 8.dp,
bottom = 8.dp,
end = 8.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
modifier = Modifier.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent,
),
text = shortcut.labelOverride ?: shortcut.label,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
shortcut.appName?.let {
Text(
text = stringResource(R.string.shortcut_summary, it),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
}
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(8.dp)
.sharedElement(
rememberSharedContentState("icon"),
this@AnimatedContent,
),
badge = { badge },
icon = { icon },
)
}
}
}
}
/*Column(
modifier = modifier modifier = modifier
) { ) {
AnimatedVisibility(showDetails && shortcut.isUnavailable) { AnimatedVisibility(showDetails && shortcut.isUnavailable) {
@ -146,15 +373,12 @@ fun AppShortcutItem(
) )
} }
} }
val badge by viewModel.badge.collectAsState(null)
val size by animateDpAsState(if (showDetails) 84.dp else 48.dp)
val icon by viewModel.icon.collectAsStateWithLifecycle()
val padding by transition.animateDp(label = "iconPadding") { val padding by transition.animateDp(label = "iconPadding") {
if (it) 16.dp else 8.dp if (it) 16.dp else 8.dp
} }
ShapedLauncherIcon( ShapedLauncherIcon(
size = size, size = 48.dp,
modifier = Modifier modifier = Modifier
.padding(padding), .padding(padding),
badge = { badge }, badge = { badge },
@ -231,7 +455,7 @@ fun AppShortcutItem(
rightActions = toolbarActions rightActions = toolbarActions
) )
} }
} }*/
if (requestDelete) { if (requestDelete) {
AlertDialog( AlertDialog(
@ -278,10 +502,6 @@ fun ShortcutItemGridPopup(
AppShortcutItem( AppShortcutItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset( .offset(
x = 16.dp * (1 - animationProgress).pow(10), x = 16.dp * (1 - animationProgress).pow(10),
y = -16.dp * (1 - animationProgress), y = -16.dp * (1 - animationProgress),

View File

@ -1,19 +1,21 @@
package de.mm20.launcher2.ui.launcher.search.website package de.mm20.launcher2.ui.launcher.search.website
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandIn import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline import androidx.compose.material.icons.rounded.StarOutline
@ -26,13 +28,16 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.roundToIntRect
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest
import de.mm20.launcher2.search.Website import de.mm20.launcher2.search.Website
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -49,6 +54,7 @@ import de.mm20.launcher2.ui.locals.LocalGridSettings
fun WebsiteItem( fun WebsiteItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
website: Website, website: Website,
showDetails: Boolean = false,
onBack: (() -> Unit)? = null onBack: (() -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -60,96 +66,212 @@ fun WebsiteItem(
viewModel.init(website, iconSize.toInt()) viewModel.init(website, iconSize.toInt())
} }
Column( SharedTransitionScope {
modifier = modifier.clickable { AnimatedContent(
viewModel.launch(context) showDetails,
} modifier = it then modifier,
) { ) { showDetails ->
if (website.imageUrl != null) { Column {
AsyncImage( if (!showDetails) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.aspectRatio(16f / 9f) verticalAlignment = if (website.imageUrl == null && website.description == null) Alignment.CenterVertically
.background(MaterialTheme.colorScheme.secondaryContainer), else Alignment.Top,
model = website.imageUrl, ) {
contentScale = ContentScale.Crop, Column(
contentDescription = null modifier = Modifier
) .weight(1f)
} .padding(16.dp)
Column( ) {
modifier = Modifier.padding(16.dp), Text(
) { text = website.labelOverride ?: website.label,
Text( style = MaterialTheme.typography.titleMedium,
text = website.labelOverride ?: website.label, modifier = Modifier
style = MaterialTheme.typography.titleLarge .sharedBounds(
) rememberSharedContentState("title"),
val tags by viewModel.tags.collectAsState(emptyList()) this@AnimatedContent,
if (tags.isNotEmpty()) { ),
Text( maxLines = 2,
modifier = Modifier.padding(top = 2.dp, bottom = 2.dp), overflow = TextOverflow.Ellipsis,
text = tags.joinToString(separator = " #", prefix = "#"), )
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
Text(
modifier = Modifier.padding(vertical = 8.dp),
text = website.description ?: "",
style = MaterialTheme.typography.bodySmall
)
}
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) { Text(
val isPinned by viewModel.isPinned.collectAsState(false) modifier = Modifier
val favAction = if (isPinned) { .padding(top = 4.dp, bottom = 4.dp)
DefaultToolbarAction( .sharedBounds(
label = stringResource(R.string.menu_favorites_unpin), rememberSharedContentState("summary"),
icon = Icons.Rounded.Star, this@AnimatedContent,
action = { ),
viewModel.unpin() text = website.description ?: website.url,
onBack?.invoke() style = MaterialTheme.typography.bodySmall,
)
}
if (!website.imageUrl.isNullOrEmpty()) {
AsyncImage(
modifier = Modifier
.padding(end = 12.dp, top = 12.dp, bottom = 12.dp)
.size(72.dp)
.sharedBounds(
rememberSharedContentState("image"),
this@AnimatedContent,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds
)
.background(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.small),
model = website.imageUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
} else if (website.faviconUrl != null) {
AsyncImage(
modifier = Modifier
.padding(end = 16.dp, top = 12.dp, bottom = 12.dp)
.sharedElement(
rememberSharedContentState("favicon"),
this@AnimatedContent,
)
.background(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.shapes.small
)
.size(48.dp)
.padding(8.dp)
.clip(MaterialTheme.shapes.small),
model = website.faviconUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
}
} else {
if (!website.imageUrl.isNullOrEmpty()) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.sharedBounds(
rememberSharedContentState("image"),
this@AnimatedContent,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds
)
.background(MaterialTheme.colorScheme.secondaryContainer),
model = ImageRequest.Builder(context).data(website.imageUrl)
.crossfade(false).build(),
contentScale = ContentScale.Crop,
contentDescription = null
)
} }
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
onBack?.invoke()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add( Row(
DefaultToolbarAction( modifier = Modifier.fillMaxWidth()
label = stringResource(R.string.menu_share), ) {
icon = Icons.Rounded.Share, Column(
action = { modifier = Modifier.weight(1f).padding(
website.share(context) start = 16.dp,
end = 16.dp,
top = 16.dp,
)
) {
Text(
text = website.labelOverride ?: website.label,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.sharedBounds(
rememberSharedContentState("title"),
this@AnimatedContent,
),
)
}
if (website.imageUrl == null && website.faviconUrl != null) {
AsyncImage(
modifier = Modifier
.padding(end = 12.dp, top = 12.dp, bottom = 12.dp)
.sharedElement(
rememberSharedContentState("favicon"),
this@AnimatedContent,
)
.background(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.shapes.small
)
.size(48.dp)
.padding(8.dp)
.clip(MaterialTheme.shapes.small),
model = website.faviconUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
}
Text(
modifier = Modifier
.padding(16.dp)
.sharedBounds(
rememberSharedContentState("summary"),
this@AnimatedContent,
),
text = website.description ?: website.url,
style = MaterialTheme.typography.bodySmall,
)
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
action = {
viewModel.unpin()
onBack?.invoke()
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_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 = {
website.share(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Tune,
action = { sheetManager.showCustomizeSearchableModal(website) }
))
Toolbar(
leftActions = if (onBack != null) listOf(
DefaultToolbarAction(
stringResource(id = R.string.menu_back),
icon = Icons.AutoMirrored.Rounded.ArrowBack,
action = onBack
)
) else emptyList(),
rightActions = toolbarActions
)
} }
) }
) }
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Tune,
action = { sheetManager.showCustomizeSearchableModal(website) }
))
Toolbar(
leftActions = if (onBack != null) listOf(
DefaultToolbarAction(
stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack,
action = onBack
)
) else emptyList(),
rightActions = toolbarActions
)
} }
} }
@ -176,7 +298,8 @@ fun WebsiteItemGridPopup(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
website = website, website = website,
onBack = onDismiss onBack = onDismiss,
showDetails = true,
) )
} }
} }

View File

@ -84,7 +84,7 @@ fun ArticleItem(
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
text = article.label, text = article.labelOverride ?: article.label,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.sharedBounds( modifier = Modifier.sharedBounds(
rememberSharedContentState("title"), rememberSharedContentState("title"),
@ -165,7 +165,7 @@ fun ArticleItem(
) )
) { ) {
Text( Text(
text = article.label, text = article.labelOverride ?: article.label,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.sharedBounds( modifier = Modifier.sharedBounds(
rememberSharedContentState("title"), rememberSharedContentState("title"),

View File

@ -2,6 +2,8 @@ package de.mm20.launcher2.search
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.os.Process
import android.os.UserHandle
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.mm20.launcher2.base.R import de.mm20.launcher2.base.R
import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.ColorLayer
@ -13,6 +15,8 @@ interface AppShortcut : SavableSearchable {
val appName: String? val appName: String?
val componentName: ComponentName? val componentName: ComponentName?
val packageName: String? val packageName: String?
val user: UserHandle
get() = Process.myUserHandle()
override val preferDetailsOverLaunch: Boolean override val preferDetailsOverLaunch: Boolean
get() = false get() = false

View File

@ -964,4 +964,8 @@
<item quantity="one">%1$s postal address</item> <item quantity="one">%1$s postal address</item>
<item quantity="other">%1$s postal addresses</item> <item quantity="other">%1$s postal addresses</item>
</plurals> </plurals>
<plurals name="app_info_notifications">
<item quantity="one">%1$s notification</item>
<item quantity="other">%1$s notifications</item>
</plurals>
</resources> </resources>

View File

@ -233,23 +233,8 @@ internal class PermissionsManagerImpl(
} }
override fun onResume() { override fun onResume() {
val iterator = pendingPermissionRequests.iterator() externalStoragePermissionState.value = checkPermissionOnce(PermissionGroup.ExternalStorage)
while (iterator.hasNext()) { appShortcutsPermissionState.value = checkPermissionOnce(PermissionGroup.AppShortcuts)
when (iterator.next()) {
PermissionGroup.ExternalStorage -> {
externalStoragePermissionState.value =
checkPermissionOnce(PermissionGroup.ExternalStorage)
}
PermissionGroup.AppShortcuts -> {
appShortcutsPermissionState.value =
checkPermissionOnce(PermissionGroup.AppShortcuts)
}
else -> {}
}
iterator.remove()
}
} }
override fun reportNotificationListenerState(running: Boolean) { override fun reportNotificationListenerState(running: Boolean) {

View File

@ -29,6 +29,10 @@ import org.apache.commons.text.similarity.FuzzyScore
import java.util.Locale import java.util.Locale
interface AppRepository : SearchableRepository<Application> { interface AppRepository : SearchableRepository<Application> {
fun findOne(
packageName: String,
user: UserHandle,
): Flow<Application?>
fun findMany(): Flow<ImmutableList<Application>> fun findMany(): Flow<ImmutableList<Application>>
} }
@ -195,6 +199,17 @@ internal class AppRepositoryImpl(
return LauncherApp(context, launcherActivityInfo) return LauncherApp(context, launcherActivityInfo)
} }
override fun findOne(
packageName: String,
user: UserHandle,
): Flow<Application?> {
return installedApps.map {
it.firstOrNull {
it.componentName.packageName == packageName && it.user == user
}
}
}
override fun findMany(): Flow<ImmutableList<Application>> { override fun findMany(): Flow<ImmutableList<Application>> {
return installedApps.map { it.toImmutableList() } return installedApps.map { it.toImmutableList() }
} }

View File

@ -42,8 +42,11 @@ class LauncherShortcutDeserializer(
val id = json.getString("id") val id = json.getString("id")
val userSerial = json.optLong("user") val userSerial = json.optLong("user")
val userManager = context.getSystemService<UserManager>()!!
val user = userManager.getUserForSerialNumber(userSerial) ?: return null
if (!launcherApps.hasShortcutHostPermission()) { if (!launcherApps.hasShortcutHostPermission()) {
return UnavailableShortcut(context, id, packageName, userSerial) return UnavailableShortcut(context, id, packageName, user, userSerial)
} }
else { else {
val query = LauncherApps.ShortcutQuery() val query = LauncherApps.ShortcutQuery()
@ -55,8 +58,6 @@ class LauncherShortcutDeserializer(
LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED_BY_ANY_LAUNCHER
) )
query.setShortcutIds(mutableListOf(id)) query.setShortcutIds(mutableListOf(id))
val userManager = context.getSystemService<UserManager>()!!
val user = userManager.getUserForSerialNumber(userSerial) ?: return null
val shortcuts = try { val shortcuts = try {
launcherApps.getShortcuts(query, user) launcherApps.getShortcuts(query, user)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {

View File

@ -10,6 +10,7 @@ import android.content.pm.ShortcutInfo
import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Bundle import android.os.Bundle
import android.os.Process import android.os.Process
import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
@ -41,6 +42,9 @@ internal data class LauncherShortcut(
override val packageName: String override val packageName: String
get() = launcherShortcut.`package` get() = launcherShortcut.`package`
override val user: UserHandle
get() = launcherShortcut.userHandle
constructor( constructor(
context: Context, context: Context,
launcherShortcut: ShortcutInfo, launcherShortcut: ShortcutInfo,

View File

@ -5,6 +5,7 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.Process import android.os.Process
import android.os.UserHandle
import android.os.UserManager import android.os.UserManager
import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon import de.mm20.launcher2.icons.StaticLauncherIcon
@ -24,15 +25,15 @@ internal class UnavailableShortcut(
override val packageName: String, override val packageName: String,
val shortcutId: String, val shortcutId: String,
val isMainProfile: Boolean, val isMainProfile: Boolean,
override val user: UserHandle,
val userSerial: Long, val userSerial: Long,
): AppShortcut { ): AppShortcut {
override val key: String override val key: String
get() = if (isMainProfile) { get() = if (isMainProfile) {
"$domain://${packageName}/${shortcutId}" "$domain://${packageName}/${shortcutId}"
} else { } else {
"$domain://${packageName}/${shortcutId}:userSerial" "$domain://${packageName}/${shortcutId}:$userSerial"
} }
override val labelOverride: String? override val labelOverride: String?
@ -70,19 +71,19 @@ internal class UnavailableShortcut(
get() = if (isMainProfile) AppProfile.Personal else AppProfile.Work get() = if (isMainProfile) AppProfile.Personal else AppProfile.Work
companion object { companion object {
internal operator fun invoke(context: Context, id: String, packageName: String, userSerial: Long): UnavailableShortcut? { internal operator fun invoke(context: Context, id: String, packageName: String, user: UserHandle, userSerial: Long): UnavailableShortcut? {
val appInfo = try { val appInfo = try {
context.packageManager.getApplicationInfo(packageName, 0) context.packageManager.getApplicationInfo(packageName, 0)
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
return null return null
} }
val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager
return UnavailableShortcut( return UnavailableShortcut(
label = context.getString(R.string.shortcut_label_unavailable), label = context.getString(R.string.shortcut_label_unavailable),
appName = appInfo.loadLabel(context.packageManager).toString(), appName = appInfo.loadLabel(context.packageManager).toString(),
packageName = packageName, packageName = packageName,
shortcutId = id, shortcutId = id,
isMainProfile = userManager.getUserForSerialNumber(userSerial) == Process.myUserHandle(), isMainProfile = user == Process.myUserHandle(),
user = user,
userSerial = userSerial, userSerial = userSerial,
) )
} }

View File

@ -70,10 +70,10 @@ data class Notification(
get() = extras.getInt(NotificationCompat.EXTRA_PROGRESS_MAX).takeIf { it > 0 } get() = extras.getInt(NotificationCompat.EXTRA_PROGRESS_MAX).takeIf { it > 0 }
val title: String? val title: String?
get() = extras.getString(NotificationCompat.EXTRA_TITLE) get() = extras.getString(NotificationCompat.EXTRA_TITLE)?.takeIf { it.isNotBlank() }
val text: String? val text: String?
get() = extras.getString(NotificationCompat.EXTRA_TEXT) get() = extras.getString(NotificationCompat.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
val isGroupSummary: Boolean val isGroupSummary: Boolean
get() = flags and NotificationCompat.FLAG_GROUP_SUMMARY != 0 get() = flags and NotificationCompat.FLAG_GROUP_SUMMARY != 0

View File

@ -16,10 +16,10 @@ import java.util.concurrent.ExecutionException
internal data class WebsiteImpl( internal data class WebsiteImpl(
override val label: String, override val label: String,
override val url: String, override val url: String,
override val description: String, override val description: String?,
override val imageUrl: String, override val imageUrl: String?,
override val faviconUrl: String, override val faviconUrl: String?,
override val color: Int, override val color: Int?,
override val labelOverride: String? = null, override val labelOverride: String? = null,
) : Website { ) : Website {
@ -38,7 +38,7 @@ internal data class WebsiteImpl(
size: Int, size: Int,
themed: Boolean, themed: Boolean,
): LauncherIcon? { ): LauncherIcon? {
if (faviconUrl.isEmpty()) return null if (faviconUrl == null) return null
try { try {
val request = ImageRequest.Builder(context) val request = ImageRequest.Builder(context)
.data(faviconUrl) .data(faviconUrl)
@ -61,7 +61,7 @@ internal data class WebsiteImpl(
} }
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon { override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
val color = if (color != 0) color else 0xFFF76F8E.toInt() val color = color ?: 0xFFF76F8E.toInt()
if (label.isNotBlank()) { if (label.isNotBlank()) {
return StaticLauncherIcon( return StaticLauncherIcon(
foregroundLayer = TextLayer(text = label[0].toString(), color = color), foregroundLayer = TextLayer(text = label[0].toString(), color = color),

View File

@ -95,10 +95,10 @@ internal class WebsiteRepository(
return@withContext WebsiteImpl( return@withContext WebsiteImpl(
label = title, label = title,
url = url, url = url,
description = description, description = description.takeIf { it.isNotBlank() },
imageUrl = image, imageUrl = image.takeIf { it.isNotBlank() },
faviconUrl = favicon, faviconUrl = favicon.takeIf { it.isNotBlank() },
color = color color = color.takeIf { it != 0 }
) )
} catch (e: IOException) { } catch (e: IOException) {
//Ignore. Not a HTML page or no connection. No result for this query //Ignore. Not a HTML page or no connection. No result for this query

View File

@ -28,11 +28,11 @@ class WebsiteDeserializer: SearchableDeserializer {
val json = JSONObject(serialized) val json = JSONObject(serialized)
return WebsiteImpl( return WebsiteImpl(
label = json.getString("label"), label = json.getString("label"),
faviconUrl = json.getString("favicon"), faviconUrl = json.getString("favicon").takeIf { it.isNotBlank() },
imageUrl = json.getString("image"), imageUrl = json.getString("image").takeIf { it.isNotBlank() },
description = json.getString("description"), description = json.getString("description").takeIf { it.isNotBlank() },
url = json.getString("url"), url = json.getString("url"),
color = json.getInt("color") color = json.getInt("color").takeIf { it != 0 }
) )
} }
} }

View File

@ -104,7 +104,7 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
androidx-securitycrypto = { group = "androidx.security", name = "security-crypto", version = "1.1.0-alpha03" } androidx-securitycrypto = { group = "androidx.security", name = "security-crypto", version = "1.1.0-alpha03" }
androidx-datastore = { group = "androidx.datastore", name = "datastore", version = "1.0.0" } androidx-datastore = { group = "androidx.datastore", name = "datastore", version = "1.0.0" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.8.0-beta03" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.8.0-beta04" }
materialcomponents-core = { group = "com.google.android.material", name = "material", version = "1.11.0" } materialcomponents-core = { group = "com.google.android.material", name = "material", version = "1.11.0" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0" }