Redesign contact results

This commit is contained in:
MM20 2024-06-23 22:31:11 +02:00
parent 29f8440b24
commit 84582ebbaf
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
6 changed files with 596 additions and 329 deletions

View File

@ -1,75 +1,83 @@
package de.mm20.launcher2.ui.launcher.search.contacts 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.AnimatedVisibility
import androidx.compose.animation.SharedTransitionLayout
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.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.border
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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.lazy.LazyRow import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
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.Message import androidx.compose.material.icons.automirrored.rounded.Message
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.NavigateNext
import androidx.compose.material.icons.rounded.Call 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.Edit
import androidx.compose.material.icons.rounded.Email 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.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.Star
import androidx.compose.material.icons.rounded.StarOutline 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.Icon
import androidx.compose.material3.IconButton
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.VerticalDivider
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.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.graphics.vector.ImageVector
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.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 de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.ContactInfoType import de.mm20.launcher2.search.ContactInfoType
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.Chip
import de.mm20.launcher2.ui.component.DefaultToolbarAction import de.mm20.launcher2.ui.component.DefaultToolbarAction
import de.mm20.launcher2.ui.component.ShapedLauncherIcon import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.Toolbar import de.mm20.launcher2.ui.component.Toolbar
import de.mm20.launcher2.ui.component.ToolbarAction 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.ktx.toPixels
import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM
import de.mm20.launcher2.ui.launcher.search.listItemViewModel 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@Composable @Composable
fun ContactItem( fun ContactItem(
@ -87,158 +95,483 @@ fun ContactItem(
viewModel.init(contact, iconSize.toInt()) viewModel.init(contact, iconSize.toInt())
} }
val lifecycleOwner = LocalLifecycleOwner.current val icon by viewModel.icon.collectAsStateWithLifecycle()
val snackbarHostState = LocalSnackbarHostState.current val badge by viewModel.badge.collectAsState(null)
val transition = updateTransition(showDetails, label = "ContactItem") SharedTransitionLayout {
AnimatedContent(showDetails) { showDetails ->
Column( if (showDetails) {
modifier = modifier Column {
) {
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) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(when(type) { ShapedLauncherIcon(
ContactInfoType.Phone -> Icons.Rounded.Call size = 48.dp,
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(
modifier = Modifier modifier = Modifier
.weight(1f) .padding(end = 16.dp)
.padding(start = 16.dp), .sharedElement(
verticalAlignment = Alignment.CenterVertically rememberSharedContentState("icon"),
) { this@AnimatedContent,
items(items.toList()) { ),
Chip( icon = { icon },
modifier = Modifier.padding(end = 16.dp), badge = { badge }
text = it.label, )
onClick = { Text(
context.tryStartActivity(it.intent) 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) { if (LocalFavoritesEnabled.current) {
val isPinned by viewModel.isPinned.collectAsState(false) val isPinned by viewModel.isPinned.collectAsState(false)
val favAction = if (isPinned) { 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( DefaultToolbarAction(
label = stringResource(R.string.menu_favorites_unpin), label = stringResource(R.string.menu_contacts_open_externally),
icon = Icons.Rounded.Star, icon = Icons.Rounded.OpenInNew,
action = { 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 val sheetManager = LocalBottomSheetManager.current
toolbarActions.add(DefaultToolbarAction( toolbarActions.add(DefaultToolbarAction(
label = stringResource(R.string.menu_customize), label = stringResource(R.string.menu_customize),
icon = Icons.Rounded.Edit, icon = Icons.Rounded.Edit,
action = { sheetManager.showCustomizeSearchableModal(contact) } action = { sheetManager.showCustomizeSearchableModal(contact) }
)) ))
Toolbar( Toolbar(
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()
} }
), ),
rightActions = toolbarActions 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 @Composable
fun ContactItemGridPopup( fun ContactItemGridPopup(
contact: Contact, contact: Contact,

View File

@ -1,35 +1,46 @@
package de.mm20.launcher2.search package de.mm20.launcher2.search
import android.content.Intent import android.net.Uri
interface Contact: SavableSearchable { interface Contact : SavableSearchable {
val firstName: String val firstName: String
val lastName: String val lastName: String
val displayName: String val displayName: String
val summary: String val summary: String
val contactInfos: Iterable<ContactInfo> val phoneNumbers: List<PhoneNumber>
val emailAddresses: List<EmailAddress>
val postalAddresses: List<PostalAddress>
val contactApps: List<ContactApp>
override val preferDetailsOverLaunch: Boolean override val preferDetailsOverLaunch: Boolean
get() = true get() = true
} }
/** data class PhoneNumber(
* Type of the contact info val number: String,
* Acts as a hint for the UI, so that it can display the correct icon and group them accordingly val type: ContactInfoType,
*/ )
enum class ContactInfoType {
Phone,
Message,
Email,
Postal,
Telegram,
Whatsapp,
Signal,
Other
}
interface ContactInfo { data class EmailAddress(
val type: ContactInfoType val address: String,
val label: String val type: ContactInfoType,
val intent: Intent )
}
data class PostalAddress(
val address: String,
val type: ContactInfoType,
)
data class ContactApp(
val label: String,
val uri: Uri,
val mimeType: String,
val packageName: String,
)
enum class ContactInfoType {
Home,
Mobile,
Work,
Other,
}

View File

@ -949,4 +949,19 @@
<string name="item_visibility_calendar_default">Calendar widget &amp; search results</string> <string name="item_visibility_calendar_default">Calendar widget &amp; search results</string>
<string name="item_visibility_search_only">Search results</string> <string name="item_visibility_search_only">Search results</string>
<string name="item_visibility_hidden">Never</string> <string name="item_visibility_hidden">Never</string>
<string name="contact_info_home">Home</string>
<string name="contact_info_mobile">Home</string>
<string name="contact_info_work">Home</string>
<plurals name="contact_phone_numbers">
<item quantity="one">%1$s phone number</item>
<item quantity="other">%1$s phone numbers</item>
</plurals>
<plurals name="contact_email_addresses">
<item quantity="one">%1$s email address</item>
<item quantity="other">%1$s email addresses</item>
</plurals>
<plurals name="contact_postal_addresses">
<item quantity="one">%1$s postal address</item>
<item quantity="other">%1$s postal addresses</item>
</plurals>
</resources> </resources>

View File

@ -14,7 +14,10 @@ import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.ktx.asBitmap import de.mm20.launcher2.ktx.asBitmap
import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.ContactInfo import de.mm20.launcher2.search.ContactApp
import de.mm20.launcher2.search.EmailAddress
import de.mm20.launcher2.search.PhoneNumber
import de.mm20.launcher2.search.PostalAddress
import de.mm20.launcher2.search.SearchableSerializer import de.mm20.launcher2.search.SearchableSerializer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -24,7 +27,10 @@ internal data class AndroidContact(
override val firstName: String, override val firstName: String,
override val lastName: String, override val lastName: String,
override val displayName: String, override val displayName: String,
override val contactInfos: Iterable<ContactInfo>, override val phoneNumbers: List<PhoneNumber>,
override val emailAddresses: List<EmailAddress>,
override val postalAddresses: List<PostalAddress>,
override val contactApps: List<ContactApp>,
internal val lookupKey: String, internal val lookupKey: String,
override val labelOverride: String? = null, override val labelOverride: String? = null,
) : Contact { ) : Contact {
@ -38,7 +44,7 @@ internal data class AndroidContact(
override val summary: String override val summary: String
get() { get() {
return contactInfos.distinctBy { it.label }.take(5).joinToString(separator = ", ") { it.label } return (phoneNumbers.map { it.number } + emailAddresses.map { it.address }).joinToString(", ")
} }
override fun overrideLabel(label: String): Contact { override fun overrideLabel(label: String): Contact {

View File

@ -1,112 +0,0 @@
package de.mm20.launcher2.contacts
import android.content.Intent
import android.net.Uri
import android.provider.ContactsContract
import de.mm20.launcher2.search.ContactInfo
import de.mm20.launcher2.search.ContactInfoType
import java.net.URLEncoder
internal data class PhoneContactInfo(
val number: String,
) : ContactInfo {
override val label: String
get() = number
override val type: ContactInfoType = ContactInfoType.Phone
override val intent: Intent
get() = Intent(Intent.ACTION_VIEW)
.setData(Uri.parse("tel:$number"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
internal data class MailContactInfo(
val address: String,
) : ContactInfo {
override val label: String
get() = address
override val type: ContactInfoType = ContactInfoType.Email
override val intent: Intent
get() = Intent(Intent.ACTION_VIEW)
.setData(Uri.parse("mailto:$address"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
internal data class PostalContactInfo(
val address: String,
) : ContactInfo {
override val label: String
get() = address.replace("\n", ", ")
override val type: ContactInfoType = ContactInfoType.Postal
override val intent: Intent
get() = Intent(Intent.ACTION_VIEW)
.setData(Uri.parse("geo:0,0?q=${URLEncoder.encode(address, "utf8")}"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
internal data class TelegramContactInfo(
override val label: String,
val userId: String,
) : ContactInfo {
override val type: ContactInfoType = ContactInfoType.Telegram
override val intent: Intent
get() = Intent(Intent.ACTION_VIEW)
.setData(Uri.parse("tg:openmessage?user_id=$userId"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal companion object {
const val ItemType = "vnd.android.cursor.item/vnd.org.telegram.messenger.android.profile"
}
}
internal data class SignalContactInfo(
override val label: String,
val dataId: Long,
) : ContactInfo {
override val type: ContactInfoType = ContactInfoType.Signal
override val intent: Intent
get() = Intent(Intent.ACTION_VIEW)
.setData(
Uri.withAppendedPath(
ContactsContract.Data.CONTENT_URI,
dataId.toString()
)
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal companion object {
const val ItemType = "vnd.android.cursor.item/vnd.org.thoughtcrime.securesms.contact"
}
}
internal data class WhatsAppContactInfo(
override val label: String,
val dataId: Long,
) : ContactInfo {
override val type: ContactInfoType = ContactInfoType.Whatsapp
override val intent: Intent
get() = Intent(Intent.ACTION_VIEW)
.setData(
Uri.withAppendedPath(
ContactsContract.Data.CONTENT_URI,
dataId.toString()
)
)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal companion object {
const val ItemType = "vnd.android.cursor.item/vnd.com.whatsapp.profile"
}
}

View File

@ -1,13 +1,19 @@
package de.mm20.launcher2.contacts package de.mm20.launcher2.contacts
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.provider.ContactsContract import android.provider.ContactsContract
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings import de.mm20.launcher2.preferences.search.ContactSearchSettings
import de.mm20.launcher2.search.Contact import de.mm20.launcher2.search.Contact
import de.mm20.launcher2.search.ContactInfo import de.mm20.launcher2.search.ContactApp
import de.mm20.launcher2.search.ContactInfoType
import de.mm20.launcher2.search.EmailAddress
import de.mm20.launcher2.search.PhoneNumber
import de.mm20.launcher2.search.PostalAddress
import de.mm20.launcher2.search.SearchableRepository import de.mm20.launcher2.search.SearchableRepository
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -51,25 +57,22 @@ internal class ContactRepository(
private suspend fun getWithRawIds(id: Long, rawIds: Set<Long>): Contact? = private suspend fun getWithRawIds(id: Long, rawIds: Set<Long>): Contact? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val s = "(" + rawIds.joinToString(separator = " OR ", val s = "${ContactsContract.Data.RAW_CONTACT_ID} IN (${rawIds.joinToString(", ")})"
transform = { "${ContactsContract.Data.RAW_CONTACT_ID} = $it" }) + ")" +
" AND (${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${TelegramContactInfo.ItemType}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${WhatsAppContactInfo.ItemType}\"" +
" OR ${ContactsContract.Data.MIMETYPE} = \"${SignalContactInfo.ItemType}\"" +
")"
val dataCursor = context.contentResolver.query( val dataCursor = context.contentResolver.query(
ContactsContract.Data.CONTENT_URI, ContactsContract.Data.CONTENT_URI,
null, s, null, null null, s, null, null
) ?: return@withContext null ) ?: return@withContext null
val contactInfos = mutableSetOf<ContactInfo>()
var firstName = "" var firstName = ""
var lastName = "" var lastName = ""
var displayName = "" var displayName = ""
val phoneNumbers = mutableListOf<PhoneNumber>()
val emailAddresses = mutableListOf<EmailAddress>()
val postalAddresses = mutableListOf<PostalAddress>()
val contactApps = mutableListOf<ContactApp>()
val mimeTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE) val mimeTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.MIMETYPE)
val typeColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Contactables.TYPE)
val emailAddressColumn = val emailAddressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS) dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
val numberColumn = val numberColumn =
@ -82,25 +85,49 @@ internal class ContactRepository(
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME) dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)
val familyNameColumn = val familyNameColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME) dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME)
val data1Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA1) val accountTypeColumn = dataCursor.getColumnIndex(ContactsContract.Data.ACCOUNT_TYPE_AND_DATA_SET)
val data3Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA3) val data3Column = dataCursor.getColumnIndex(ContactsContract.Data.DATA3)
val idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID) val idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID)
loop@ while (dataCursor.moveToNext()) { loop@ while (dataCursor.moveToNext()) {
when (dataCursor.getStringOrNull(mimeTypeColumn)) { when (dataCursor.getStringOrNull(mimeTypeColumn)) {
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(emailAddressColumn)?.let { dataCursor.getStringOrNull(emailAddressColumn)?.let {
contactInfos.add(MailContactInfo(it)) emailAddresses += EmailAddress(
it,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.Email.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.Email.TYPE_WORK -> ContactInfoType.Work
ContactsContract.CommonDataKinds.Email.TYPE_MOBILE -> ContactInfoType.Mobile
else -> ContactInfoType.Other
}
)
} }
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(numberColumn)?.let { dataCursor.getStringOrNull(numberColumn)?.let {
val phone = it.replace(Regex("[^+0-9]"), "") val phone = it.replace(Regex("[^+0-9]"), "")
contactInfos.add(PhoneContactInfo(phone)) phoneNumbers += PhoneNumber(
phone,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> ContactInfoType.Work
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> ContactInfoType.Mobile
else -> ContactInfoType.Other
}
)
} }
ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE -> ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE ->
dataCursor.getStringOrNull(addressColumn)?.let { dataCursor.getStringOrNull(addressColumn)?.let {
contactInfos.add(PostalContactInfo(it)) postalAddresses += PostalAddress(
it,
when (dataCursor.getInt(typeColumn)) {
ContactsContract.CommonDataKinds.StructuredPostal.TYPE_HOME -> ContactInfoType.Home
ContactsContract.CommonDataKinds.StructuredPostal.TYPE_WORK -> ContactInfoType.Work
else -> ContactInfoType.Other
}
)
} }
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> { ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> {
@ -109,34 +136,18 @@ internal class ContactRepository(
displayName = dataCursor.getStringOrNull(displayNameColumn) ?: "" displayName = dataCursor.getStringOrNull(displayNameColumn) ?: ""
} }
TelegramContactInfo.ItemType -> { else -> {
val data1 = dataCursor.getStringOrNull(data1Column) val mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue
?: continue@loop contactApps += ContactApp(
val data3 = dataCursor.getStringOrNull(data3Column) label = dataCursor.getStringOrNull(data3Column) ?: continue,
?: continue@loop packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue,
contactInfos.add( mimeType = mimeType,
TelegramContactInfo(data3.substringAfterLast(" "), data1) uri = ContentUris.withAppendedId(
ContactsContract.Data.CONTENT_URI,
dataCursor.getLongOrNull(idColumn) ?: continue
),
) )
} }
WhatsAppContactInfo.ItemType -> {
val data1 = dataCursor.getStringOrNull(data1Column)
?: continue@loop
val dataId = dataCursor.getLong(idColumn)
contactInfos.add(
WhatsAppContactInfo(
"+${data1.substringBefore('@')}",
dataId
)
)
}
SignalContactInfo.ItemType -> {
val data1 = dataCursor.getStringOrNull(data1Column)
?: continue@loop
val dataId = dataCursor.getLong(idColumn)
contactInfos.add(SignalContactInfo(data1, dataId))
}
} }
} }
dataCursor.close() dataCursor.close()
@ -159,7 +170,10 @@ internal class ContactRepository(
firstName = firstName, firstName = firstName,
lastName = lastName, lastName = lastName,
displayName = displayName, displayName = displayName,
contactInfos = contactInfos, phoneNumbers = phoneNumbers.distinct(),
emailAddresses = emailAddresses.distinct(),
postalAddresses = postalAddresses.distinct(),
contactApps = contactApps.distinct(),
lookupKey = lookUpKey lookupKey = lookUpKey
) )
} }