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.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
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.shrinkOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
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.verticalScroll
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.ArrowBack
import androidx.compose.material.icons.rounded.Clear
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.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.Star
import androidx.compose.material.icons.rounded.StarOutline
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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.search.Application
import de.mm20.launcher2.ui.R
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.locals.LocalFavoritesEnabled
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
@Composable
@ -98,12 +95,10 @@ fun AppItem(
}
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
Column(
modifier = modifier
modifier = modifier.verticalScroll(rememberScrollState())
) {
Row {
Column(
@ -144,143 +139,215 @@ fun AppItem(
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 icon by viewModel.icon.collectAsStateWithLifecycle()
ShapedLauncherIcon(
size = 84.dp,
size = 48.dp,
modifier = Modifier
.padding(16.dp),
badge = { badge },
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>()
@ -316,7 +383,7 @@ fun AppItem(
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_launch),
icon = Icons.Rounded.OpenInNew,
icon = Icons.AutoMirrored.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
@ -384,7 +451,7 @@ fun AppItem(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
icon = Icons.AutoMirrored.Rounded.ArrowBack
) {
onBack()
}
@ -416,10 +483,6 @@ fun AppItemGridPopup(
AppItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 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.compose.ui.geometry.Rect
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.badges.BadgeService
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.Application
import de.mm20.launcher2.search.File
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.UpdatableSearchable
import de.mm20.launcher2.search.UpdateResult
@ -36,12 +35,12 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalCoroutinesApi::class)
@ -51,6 +50,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
private val iconService: IconService by inject()
private val tagsService: TagsService by inject()
private val notificationRepository: NotificationRepository by inject()
private val appRepository: AppRepository by inject()
private val appShortcutRepository: AppShortcutRepository by inject()
private val permissionsManager: PermissionsManager 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 } }
}
val shortcuts = searchable.map {
if (it !is Application) emptyList()
else appShortcutRepository
.findMany(
componentName = it.componentName,
user = it.user,
manifest = true,
dynamic = true,
).first()
val children = searchable.flatMapLatest {
when(it) {
is Application -> appShortcutRepository
.findMany(
componentName = it.componentName,
user = it.user,
manifest = true,
dynamic = true,
)
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 {
@ -134,24 +149,26 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
notificationRepository.cancelNotification(notification)
}
fun getShortcutIcon(context: Context, shortcut: AppShortcut, size: Int): Flow<LauncherIcon?> {
return iconService.getIcon(shortcut, size)
fun getChildIcon(child: SavableSearchable, size: Int): Flow<LauncherIcon?> {
return iconService.getIcon(child, size)
}
fun isShortcutPinned(shortcut: AppShortcut): Flow<Boolean> {
return favoritesService.isPinned(shortcut)
fun isChildPinned(child: SavableSearchable): Flow<Boolean> {
return favoritesService.isPinned(child)
}
fun pinShortcut(shortcut: AppShortcut) {
favoritesService.pinItem(shortcut)
fun pinChild(child: SavableSearchable) {
favoritesService.pinItem(child)
}
fun unpinShortcut(shortcut: AppShortcut) {
favoritesService.unpinItem(shortcut)
fun unpinChild(child: SavableSearchable) {
favoritesService.unpinItem(child)
}
fun launchShortcut(context: Context, shortcut: AppShortcut) {
shortcut.launch(context, null)
fun launchChild(context: Context, child: SavableSearchable) {
if(child.launch(context, null)) {
favoritesService.reportLaunch(child)
}
}
fun delete(context: Context) {

View File

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

View File

@ -5,33 +5,35 @@ import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionLayout
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.updateTransition
import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
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.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
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.Star
import androidx.compose.material.icons.rounded.StarOutline
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.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@ -43,20 +45,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.AppShortcut
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.MissingPermissionBanner
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.locals.LocalFavoritesEnabled
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 kotlin.math.pow
@Composable
@ -89,14 +87,243 @@ fun AppShortcutItem(
viewModel.init(shortcut, iconSize.toInt())
}
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val badge by viewModel.badge.collectAsState(null)
val icon by viewModel.icon.collectAsStateWithLifecycle()
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
) {
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") {
if (it) 16.dp else 8.dp
}
ShapedLauncherIcon(
size = size,
size = 48.dp,
modifier = Modifier
.padding(padding),
badge = { badge },
@ -231,7 +455,7 @@ fun AppShortcutItem(
rightActions = toolbarActions
)
}
}
}*/
if (requestDelete) {
AlertDialog(
@ -278,10 +502,6 @@ fun ShortcutItemGridPopup(
AppShortcutItem(
modifier = Modifier
.fillMaxWidth()
.scale(
1 - (1 - LocalGridSettings.current.iconSize / 84f) * (1 - animationProgress),
transformOrigin = TransformOrigin(1f, 0f)
)
.offset(
x = 16.dp * (1 - animationProgress).pow(10),
y = -16.dp * (1 - animationProgress),

View File

@ -1,19 +1,21 @@
package de.mm20.launcher2.ui.launcher.search.website
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
@ -26,13 +28,16 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import coil.compose.AsyncImage
import coil.request.ImageRequest
import de.mm20.launcher2.search.Website
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.DefaultToolbarAction
@ -49,6 +54,7 @@ import de.mm20.launcher2.ui.locals.LocalGridSettings
fun WebsiteItem(
modifier: Modifier = Modifier,
website: Website,
showDetails: Boolean = false,
onBack: (() -> Unit)? = null
) {
val context = LocalContext.current
@ -60,96 +66,212 @@ fun WebsiteItem(
viewModel.init(website, iconSize.toInt())
}
Column(
modifier = modifier.clickable {
viewModel.launch(context)
}
) {
if (website.imageUrl != null) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.secondaryContainer),
model = website.imageUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = website.labelOverride ?: website.label,
style = MaterialTheme.typography.titleLarge
)
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 2.dp, bottom = 2.dp),
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>()
SharedTransitionScope {
AnimatedContent(
showDetails,
modifier = it then modifier,
) { showDetails ->
Column {
if (!showDetails) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = if (website.imageUrl == null && website.description == null) Alignment.CenterVertically
else Alignment.Top,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
) {
Text(
text = website.labelOverride ?: website.label,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.sharedBounds(
rememberSharedContentState("title"),
this@AnimatedContent,
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
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()
Text(
modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp)
.sharedBounds(
rememberSharedContentState("summary"),
this@AnimatedContent,
),
text = website.description ?: website.url,
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(
DefaultToolbarAction(
label = stringResource(R.string.menu_share),
icon = Icons.Rounded.Share,
action = {
website.share(context)
Row(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.weight(1f).padding(
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
.fillMaxWidth(),
website = website,
onBack = onDismiss
onBack = onDismiss,
showDetails = true,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,10 @@ import org.apache.commons.text.similarity.FuzzyScore
import java.util.Locale
interface AppRepository : SearchableRepository<Application> {
fun findOne(
packageName: String,
user: UserHandle,
): Flow<Application?>
fun findMany(): Flow<ImmutableList<Application>>
}
@ -195,6 +199,17 @@ internal class AppRepositoryImpl(
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>> {
return installedApps.map { it.toImmutableList() }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -95,10 +95,10 @@ internal class WebsiteRepository(
return@withContext WebsiteImpl(
label = title,
url = url,
description = description,
imageUrl = image,
faviconUrl = favicon,
color = color
description = description.takeIf { it.isNotBlank() },
imageUrl = image.takeIf { it.isNotBlank() },
faviconUrl = favicon.takeIf { it.isNotBlank() },
color = color.takeIf { it != 0 }
)
} catch (e: IOException) {
//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)
return WebsiteImpl(
label = json.getString("label"),
faviconUrl = json.getString("favicon"),
imageUrl = json.getString("image"),
description = json.getString("description"),
faviconUrl = json.getString("favicon").takeIf { it.isNotBlank() },
imageUrl = json.getString("image").takeIf { it.isNotBlank() },
description = json.getString("description").takeIf { it.isNotBlank() },
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-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" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0" }