ContactItem improvements (#1324)

* ContactRepository: try to deduplicate phoneNumbers in a smart way

* AndroidManifest: use CALL_PHONE permission to allow for making phone calls

* Implement CallOnTap with contact results

- contact activities that require permissions we do not have are not
  listed
- add CallOnTap setting for phone number results on search behind
  contacts settings

* utilize PhoneNumberUtils

* navroute settings/search/contacts

* queryIntentActivities -> resolveActivity

* localization

* Code formatting

* Wrap contact search settings in preference category

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph
2025-03-29 17:18:30 +01:00
committed by GitHub
co-authored by MM20
parent 21894d8df2
commit bceae1aa58
14 changed files with 191 additions and 10 deletions
@@ -16,6 +16,7 @@ import de.mm20.launcher2.notifications.Notification
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.AppShortcut
import de.mm20.launcher2.search.Application
@@ -53,6 +54,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
private val appShortcutRepository: AppShortcutRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val locationSearchSettings: LocationSearchSettings by inject()
private val contactSearchSettings: ContactSearchSettings by inject()
val isUpToDate = MutableStateFlow(true)
@@ -247,6 +249,9 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
.map { it ?: LocationSearchSettings.DefaultTileServerUrl }
.stateIn(viewModelScope, SharingStarted.Lazily, "")
val callOnTap = contactSearchSettings.callOnTap
.stateIn(viewModelScope, SharingStarted.Lazily, false)
fun reportUsage(searchable: SavableSearchable) {
favoritesService.reportLaunch(searchable)
}
@@ -83,6 +83,8 @@ import de.mm20.launcher2.ui.modifier.scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import androidx.core.net.toUri
import de.mm20.launcher2.ktx.checkPermission
@Composable
fun ContactItem(
@@ -101,6 +103,7 @@ fun ContactItem(
}
val icon by viewModel.icon.collectAsStateWithLifecycle()
val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(false)
val badge by viewModel.badge.collectAsState(null)
SharedTransitionLayout {
@@ -183,9 +186,12 @@ fun ContactItem(
onContact = {
viewModel.reportUsage(contact)
context.tryStartActivity(
Intent(Intent.ACTION_DIAL).apply {
data = Uri.parse("tel:${it.number}")
}
Intent(
if (callOnTap)
Intent.ACTION_CALL
else
Intent.ACTION_DIAL
).setData("tel:${it.number}".toUri())
)
},
copyText = { it.number },
@@ -300,11 +306,22 @@ fun ContactItem(
app.key
}
}
val itemsWithPermission = remember(app) {
app.value.filter {
// exclude activities we have no permission for
val resolvedActivityInfo = context.packageManager.resolveActivity(
Intent(Intent.ACTION_VIEW).setDataAndType(it.uri, it.mimeType),
0
)?.activityInfo ?: return@filter false
resolvedActivityInfo.permission == null || context.checkPermission(resolvedActivityInfo.permission)
}
}
ContactInfo(
icon = Icons.AutoMirrored.Rounded.OpenInNew,
customIcon = appIcon,
label = label,
items = app.value,
items = itemsWithPermission,
itemLabel = { it.label },
expanded = expandedSection == 3 + i,
modifier = Modifier
@@ -43,6 +43,7 @@ import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen
import de.mm20.launcher2.ui.settings.cards.CardsSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ThemeSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ThemesSettingsScreen
import de.mm20.launcher2.ui.settings.contacts.ContactsSettingsScreen
import de.mm20.launcher2.ui.settings.crashreporter.CrashReportScreen
import de.mm20.launcher2.ui.settings.crashreporter.CrashReporterScreen
import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen
@@ -227,6 +228,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/favorites") {
FavoritesSettingsScreen()
}
composable("settings/search/contacts") {
ContactsSettingsScreen()
}
composable("settings/integrations") {
IntegrationsSettingsScreen()
}
@@ -0,0 +1,58 @@
package de.mm20.launcher2.ui.settings.contacts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Call
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
fun ContactsSettingsScreen() {
val viewModel: ContactsSettingsScreenVM = viewModel()
val context = LocalContext.current
val hasCallPermission by viewModel.hasCallPermission.collectAsStateWithLifecycle(null)
val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(null)
PreferenceScreen(
title = stringResource(R.string.preference_search_contacts)
) {
item {
PreferenceCategory {
AnimatedVisibility(hasCallPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_call_contacts_settings),
onClick = {
viewModel.requestCallPermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
title = stringResource(R.string.preference_contacts_call_on_tap),
summary = stringResource(R.string.preference_contacts_call_on_tap_summary),
icon = Icons.Rounded.Call,
value = callOnTap == true && hasCallPermission == true,
onValueChanged = {
viewModel.setCallOnTap(it)
},
enabled = hasCallPermission == true
)
}
}
}
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.ui.settings.contacts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ContactsSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: ContactSearchSettings by inject()
private val permissionsManager: PermissionsManager by inject()
val hasCallPermission = permissionsManager.hasPermission(PermissionGroup.Call)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun requestCallPermission(activity: AppCompatActivity) =
permissionsManager.requestPermission(activity, PermissionGroup.Call)
val callOnTap = settings.callOnTap
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun setCallOnTap(callOnTap: Boolean) =
settings.setCallOnTap(callOnTap)
}
@@ -119,14 +119,17 @@ fun SearchSettingsScreen() {
modifier = Modifier.padding(16.dp)
)
}
SwitchPreference(
PreferenceWithSwitch(
title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary),
icon = Icons.Rounded.Person,
value = contacts == true && hasContactsPermission == true,
onValueChanged = {
switchValue = contacts == true && hasContactsPermission == true,
onSwitchChanged = {
viewModel.setContacts(it)
},
onClick = {
navController?.navigate("settings/search/contacts")
},
enabled = hasContactsPermission == true
)