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
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,

View File

@ -1,35 +1,46 @@
package de.mm20.launcher2.search
import android.content.Intent
import android.net.Uri
interface Contact: SavableSearchable {
interface Contact : SavableSearchable {
val firstName: String
val lastName: String
val displayName: 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
get() = true
}
/**
* Type of the contact info
* Acts as a hint for the UI, so that it can display the correct icon and group them accordingly
*/
enum class ContactInfoType {
Phone,
Message,
Email,
Postal,
Telegram,
Whatsapp,
Signal,
Other
}
data class PhoneNumber(
val number: String,
val type: ContactInfoType,
)
interface ContactInfo {
val type: ContactInfoType
val label: String
val intent: Intent
}
data class EmailAddress(
val address: String,
val type: ContactInfoType,
)
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_search_only">Search results</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>

View File

@ -14,7 +14,10 @@ import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.ktx.asBitmap
import de.mm20.launcher2.ktx.tryStartActivity
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -24,7 +27,10 @@ internal data class AndroidContact(
override val firstName: String,
override val lastName: 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,
override val labelOverride: String? = null,
) : Contact {
@ -38,7 +44,7 @@ internal data class AndroidContact(
override val summary: String
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 {

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
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import androidx.core.database.getLongOrNull
import androidx.core.database.getStringOrNull
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -51,25 +57,22 @@ internal class ContactRepository(
private suspend fun getWithRawIds(id: Long, rawIds: Set<Long>): Contact? =
withContext(Dispatchers.IO) {
val s = "(" + rawIds.joinToString(separator = " OR ",
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 s = "${ContactsContract.Data.RAW_CONTACT_ID} IN (${rawIds.joinToString(", ")})"
val dataCursor = context.contentResolver.query(
ContactsContract.Data.CONTENT_URI,
null, s, null, null
) ?: return@withContext null
val contactInfos = mutableSetOf<ContactInfo>()
var firstName = ""
var lastName = ""
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 typeColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Contactables.TYPE)
val emailAddressColumn =
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
val numberColumn =
@ -82,25 +85,49 @@ internal class ContactRepository(
dataCursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME)
val familyNameColumn =
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 idColumn = dataCursor.getColumnIndex(ContactsContract.Data._ID)
loop@ while (dataCursor.moveToNext()) {
when (dataCursor.getStringOrNull(mimeTypeColumn)) {
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE ->
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 ->
dataCursor.getStringOrNull(numberColumn)?.let {
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 ->
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 -> {
@ -109,34 +136,18 @@ internal class ContactRepository(
displayName = dataCursor.getStringOrNull(displayNameColumn) ?: ""
}
TelegramContactInfo.ItemType -> {
val data1 = dataCursor.getStringOrNull(data1Column)
?: continue@loop
val data3 = dataCursor.getStringOrNull(data3Column)
?: continue@loop
contactInfos.add(
TelegramContactInfo(data3.substringAfterLast(" "), data1)
else -> {
val mimeType = dataCursor.getStringOrNull(mimeTypeColumn) ?: continue
contactApps += ContactApp(
label = dataCursor.getStringOrNull(data3Column) ?: continue,
packageName = dataCursor.getStringOrNull(accountTypeColumn) ?: continue,
mimeType = mimeType,
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()
@ -159,7 +170,10 @@ internal class ContactRepository(
firstName = firstName,
lastName = lastName,
displayName = displayName,
contactInfos = contactInfos,
phoneNumbers = phoneNumbers.distinct(),
emailAddresses = emailAddresses.distinct(),
postalAddresses = postalAddresses.distinct(),
contactApps = contactApps.distinct(),
lookupKey = lookUpKey
)
}