From 84582ebbaf561d0726d7ebd967b0022b8fe775b7 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sun, 23 Jun 2024 22:31:11 +0200 Subject: [PATCH] Redesign contact results --- .../launcher/search/contacts/ContactItem.kt | 631 +++++++++++++----- .../java/de/mm20/launcher2/search/Contact.kt | 55 +- core/i18n/src/main/res/values/strings.xml | 15 + .../mm20/launcher2/contacts/AndroidContact.kt | 12 +- .../de/mm20/launcher2/contacts/ContactInfo.kt | 112 ---- .../launcher2/contacts/ContactRepository.kt | 100 +-- 6 files changed, 596 insertions(+), 329 deletions(-) delete mode 100644 data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactInfo.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt index eb4755b0..f82905f3 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/contacts/ContactItem.kt @@ -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() + val toolbarActions = mutableListOf() - 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 ContactInfo( + label: String, + modifier: Modifier = Modifier, + items: List, + 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, diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt b/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt index 116243cb..09f6893c 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Contact.kt @@ -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 + val phoneNumbers: List + val emailAddresses: List + val postalAddresses: List + val contactApps: List 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 -} \ No newline at end of file +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, +} diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index 968e4ea4..e52266fd 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -949,4 +949,19 @@ Calendar widget & search results Search results Never + Home + Home + Home + + %1$s phone number + %1$s phone numbers + + + %1$s email address + %1$s email addresses + + + %1$s postal address + %1$s postal addresses + \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt index c0ad3281..89c32b9c 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/AndroidContact.kt @@ -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, + override val phoneNumbers: List, + override val emailAddresses: List, + override val postalAddresses: List, + override val contactApps: List, 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 { diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactInfo.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactInfo.kt deleted file mode 100644 index da57873c..00000000 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactInfo.kt +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt index ed2f8d92..d7635425 100644 --- a/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt +++ b/data/contacts/src/main/java/de/mm20/launcher2/contacts/ContactRepository.kt @@ -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): 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() var firstName = "" var lastName = "" var displayName = "" + val phoneNumbers = mutableListOf() + val emailAddresses = mutableListOf() + val postalAddresses = mutableListOf() + val contactApps = mutableListOf() + 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 ) }