Redesign contact results

This commit is contained in:
MM20
2024-06-23 22:31:11 +02:00
parent 29f8440b24
commit 84582ebbaf
6 changed files with 596 additions and 329 deletions
@@ -1,75 +1,83 @@
package de.mm20.launcher2.ui.launcher.search.contacts
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.net.Uri
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.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Message
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.automirrored.rounded.NavigateNext
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Directions
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Email
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Phone
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material.icons.rounded.Whatsapp
import androidx.compose.material3.Icon
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.material3.VerticalDivider
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.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.graphics.vector.ImageVector
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.roundToIntRect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.ContactInfoType
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.animation.animateTextStyleAsState
import de.mm20.launcher2.ui.component.Chip
import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction
import de.mm20.launcher2.icons.Signal
import de.mm20.launcher2.icons.Telegram
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@Composable
fun ContactItem(
@@ -87,158 +95,483 @@ fun ContactItem(
viewModel.init(contact, iconSize.toInt())
}
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = LocalSnackbarHostState.current
val icon by viewModel.icon.collectAsStateWithLifecycle()
val badge by viewModel.badge.collectAsState(null)
val transition = updateTransition(showDetails, label = "ContactItem")
Column(
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val icon by viewModel.icon.collectAsStateWithLifecycle()
val padding = 16.dp
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(start = padding, top = padding, bottom = padding),
icon = { icon },
)
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
val textStyle by animateTextStyleAsState(
if (showDetails) MaterialTheme.typography.titleLarge
else MaterialTheme.typography.titleSmall
)
Text(
text = contact.labelOverride ?: contact.label,
style = textStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(!showDetails) {
Text(
contact.summary,
modifier = Modifier.padding(top = 2.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility(showDetails) {
val tags by viewModel.tags.collectAsState(emptyList())
if (tags.isNotEmpty()) {
Text(
modifier = Modifier.padding(top = 1.dp),
text = tags.joinToString(separator = " #", prefix = "#"),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
AnimatedVisibility(showDetails) {
val groups = remember {
contact.contactInfos.groupBy { it.type }
}
Column {
for ((type, items) in groups) {
SharedTransitionLayout {
AnimatedContent(showDetails) { showDetails ->
if (showDetails) {
Column {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(when(type) {
ContactInfoType.Phone -> Icons.Rounded.Call
ContactInfoType.Message -> Icons.AutoMirrored.Rounded.Message
ContactInfoType.Email -> Icons.Rounded.Email
ContactInfoType.Postal -> Icons.Rounded.Home
ContactInfoType.Telegram -> Icons.Rounded.Telegram
ContactInfoType.Whatsapp -> Icons.Rounded.Whatsapp
ContactInfoType.Signal -> Icons.Rounded.Signal
ContactInfoType.Other -> Icons.Rounded.MoreHoriz
}, contentDescription = null)
LazyRow(
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(items.toList()) {
Chip(
modifier = Modifier.padding(end = 16.dp),
text = it.label,
onClick = {
context.tryStartActivity(it.intent)
.padding(end = 16.dp)
.sharedElement(
rememberSharedContentState("icon"),
this@AnimatedContent,
),
icon = { icon },
badge = { badge }
)
Text(
text = contact.labelOverride ?: contact.label,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent,
),
)
}
val canNavigate = remember {
context.packageManager.queryIntentActivities(
Intent(
Intent.ACTION_VIEW,
Uri.parse("google.navigation:q=")
),
0
).isNotEmpty()
}
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
var expandedSection by remember { mutableStateOf(-1) }
if (contact.phoneNumbers.isNotEmpty()) {
ContactInfo(
icon = Icons.Rounded.Phone,
label = pluralStringResource(
R.plurals.contact_phone_numbers,
contact.phoneNumbers.size,
contact.phoneNumbers.size
),
items = contact.phoneNumbers,
itemLabel = { it.number },
itemSubLabel = { it.type.toString(context) },
expanded = expandedSection == 0,
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
secondaryAction = {
IconButton(onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("smsto:${it.number}")
}
)
}) {
Icon(
Icons.AutoMirrored.Rounded.Message,
null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
)
},
onExpand = {
expandedSection = if (it) 0 else -1
},
onContact = {
context.tryStartActivity(
Intent(Intent.ACTION_DIAL).apply {
data = Uri.parse("tel:${it.number}")
}
)
}
)
}
if (contact.emailAddresses.isNotEmpty()) {
ContactInfo(
icon = Icons.Rounded.Email,
label = pluralStringResource(
R.plurals.contact_email_addresses,
contact.emailAddresses.size,
contact.emailAddresses.size
),
items = contact.emailAddresses,
itemLabel = { it.address },
itemSubLabel = { it.type.toString(context) },
expanded = expandedSection == 1,
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
onExpand = {
expandedSection = if (it) 1 else -1
},
onContact = {
context.tryStartActivity(
Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:${it.address}")
}
)
}
)
}
if (contact.postalAddresses.isNotEmpty()) {
ContactInfo(
icon = Icons.Rounded.Place,
label = pluralStringResource(
R.plurals.contact_postal_addresses,
contact.postalAddresses.size,
contact.postalAddresses.size
),
items = contact.postalAddresses,
itemLabel = { it.address },
itemSubLabel = { it.type.toString(context) },
secondaryAction = if (canNavigate) {
{
IconButton(onClick = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("google.navigation:q=${it.address}")
}
)
}) {
Icon(
Icons.Rounded.Directions,
null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else null,
expanded = expandedSection == 2,
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
onExpand = {
expandedSection = if (it) 2 else -1
},
onContact = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("geo:0,0?q=${it.address}")
}
)
}
)
}
val apps = remember(contact) {
contact.contactApps.groupBy { it.packageName }
}
for ((i, app) in apps.entries.withIndex()) {
val packageName = remember(app) {
context.packageManager.queryIntentActivities(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
app.value.first().uri,
app.value.first().mimeType
)
},
0,
).firstOrNull()?.activityInfo?.packageName
} ?: continue
val appIcon by remember(app) {
flow {
try {
emit(context.packageManager.getApplicationIcon(packageName))
} catch (e: PackageManager.NameNotFoundException) {
emit(null)
}
}.flowOn(Dispatchers.IO)
}.collectAsState(null)
val label = remember(app) {
try {
context.packageManager.getApplicationInfo(packageName, 0)
.loadLabel(context.packageManager).toString()
} catch (e: PackageManager.NameNotFoundException) {
app.key
}
}
ContactInfo(
icon = Icons.AutoMirrored.Rounded.OpenInNew,
customIcon = appIcon,
label = label,
items = app.value,
itemLabel = { it.label },
expanded = expandedSection == 3 + i,
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
onExpand = {
expandedSection = if (it) 3 + i else -1
},
onContact = {
context.tryStartActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
it.uri,
it.mimeType
)
}
)
}
)
}
}
}
val toolbarActions = mutableListOf<ToolbarAction>()
val toolbarActions = mutableListOf<ToolbarAction>()
if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) {
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)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin),
icon = Icons.Rounded.Star,
label = stringResource(R.string.menu_contacts_open_externally),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.unpin()
viewModel.launch(context)
}
)
} else {
DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_pin),
icon = Icons.Rounded.StarOutline,
action = {
viewModel.pin()
})
}
toolbarActions.add(favAction)
}
toolbarActions.add(
DefaultToolbarAction(
label = stringResource(R.string.menu_contacts_open_externally),
icon = Icons.Rounded.OpenInNew,
action = {
viewModel.launch(context)
}
)
)
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(contact) }
))
val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(contact) }
))
Toolbar(
leftActions = listOf(
DefaultToolbarAction(
label = stringResource(id = R.string.menu_back),
icon = Icons.Rounded.ArrowBack
) {
onBack()
}
),
rightActions = toolbarActions
)
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 = 8.dp,
top = 8.dp,
bottom = 8.dp,
end = 16.dp
),
verticalAlignment = Alignment.CenterVertically,
) {
ShapedLauncherIcon(
size = 48.dp,
modifier = Modifier
.padding(8.dp)
.sharedElement(
rememberSharedContentState("icon"),
this@AnimatedContent,
),
icon = { icon },
badge = { badge }
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
modifier = Modifier.sharedBounds(
rememberSharedContentState("label"),
this@AnimatedContent,
),
text = contact.labelOverride ?: contact.label,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
contact.summary,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
}
}
@Composable
private fun <T> ContactInfo(
label: String,
modifier: Modifier = Modifier,
items: List<T>,
itemLabel: (T) -> String,
itemSubLabel: (T) -> String? = { null },
itemIcon: (T) -> ImageVector? = { null },
secondaryAction: (@Composable (T) -> Unit)? = null,
icon: ImageVector,
customIcon: Drawable? = null,
expanded: Boolean,
onExpand: (Boolean) -> Unit,
onContact: (T) -> Unit,
) {
Row(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.clickable(
enabled = !expanded && items.size != 1
) {
if (items.size > 1) {
onExpand(true)
} else {
onContact(items.first())
}
}
.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
MaterialTheme.shapes.small
),
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(expanded || items.size == 1) { exp ->
if (exp) {
Column {
for (item in items) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = if (secondaryAction != null) 8.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier =
Modifier
.weight(1f)
.clickable {
onContact(item)
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (customIcon != null) {
AsyncImage(
model = customIcon,
contentDescription = null,
modifier = Modifier
.padding(horizontal = 4.dp)
.size(24.dp)
)
} else {
Icon(
itemIcon(item) ?: icon,
null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
) {
itemSubLabel(item)?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(bottom = 2.dp)
)
}
Text(
text = itemLabel(item),
style = MaterialTheme.typography.titleSmall,
)
}
}
if (secondaryAction != null) {
VerticalDivider(
modifier = Modifier
.height(24.dp)
.padding(end = 4.dp)
)
secondaryAction(item)
}
}
}
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (customIcon != null) {
AsyncImage(
model = customIcon,
contentDescription = null,
modifier = Modifier
.padding(horizontal = 4.dp)
.size(24.dp)
)
} else {
Icon(
icon,
null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
)
Icon(
Icons.AutoMirrored.Rounded.NavigateNext,
null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
private fun ContactInfoType.toString(context: Context): String? {
return when (this) {
ContactInfoType.Home -> context.getString(R.string.contact_info_home)
ContactInfoType.Mobile -> context.getString(R.string.contact_info_mobile)
ContactInfoType.Work -> context.getString(R.string.contact_info_work)
ContactInfoType.Other -> null
}
}
@Composable
fun ContactItemGridPopup(
contact: Contact,