Add contact plugin settings

This commit is contained in:
MM20 2025-04-06 14:10:48 +02:00
parent 02cec92d72
commit ee9312a887
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
11 changed files with 200 additions and 46 deletions

View File

@ -1,23 +1,34 @@
package de.mm20.launcher2.ui.settings.contacts
import android.app.PendingIntent
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.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
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.PreferenceWithSwitch
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
@ -25,9 +36,10 @@ fun ContactsSettingsScreen() {
val viewModel: ContactsSettingsScreenVM = viewModel()
val context = LocalContext.current
val localContacts by viewModel.localContacts.collectAsStateWithLifecycle(null)
val hasContactsPermission by viewModel.hasContactsPermission.collectAsStateWithLifecycle(null)
val hasCallPermission by viewModel.hasCallPermission.collectAsStateWithLifecycle(null)
val plugins by viewModel.availablePlugins.collectAsStateWithLifecycle(emptyList(), minActiveState = Lifecycle.State.RESUMED)
val enabledProviders by viewModel.enabledProviders.collectAsState(emptySet())
val callOnTap by viewModel.callOnTap.collectAsStateWithLifecycle(null)
PreferenceScreen(
@ -48,11 +60,44 @@ fun ContactsSettingsScreen() {
title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary),
icon = Icons.Rounded.Person,
value = localContacts == true,
value = enabledProviders.contains("local"),
onValueChanged = {
viewModel.setLocalContacts(it)
viewModel.setProviderEnabled("local", it)
}
)
for (plugin in plugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message
?: stringResource(id = R.string.plugin_state_setup_required),
icon = Icons.Rounded.ErrorOutline,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission(context)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(id = R.string.plugin_action_setup))
}
}
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = state is PluginState.Ready,
summary = (state as? PluginState.SetupRequired)?.message
?: (state as? PluginState.Ready)?.text
?: plugin.plugin.description,
value = enabledProviders.contains(plugin.plugin.authority) && state is PluginState.Ready,
onValueChanged = {
viewModel.setProviderEnabled(plugin.plugin.authority, it)
},
)
}
}
PreferenceCategory {
AnimatedVisibility(hasCallPermission == false) {

View File

@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@ -15,6 +17,7 @@ class ContactsSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: ContactSearchSettings by inject()
private val permissionsManager: PermissionsManager by inject()
private val pluginService: PluginService by inject()
val hasCallPermission = permissionsManager.hasPermission(PermissionGroup.Call)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
@ -34,10 +37,14 @@ class ContactsSettingsScreenVM : ViewModel(), KoinComponent {
permissionsManager.requestPermission(activity, PermissionGroup.Contacts)
}
val localContacts = settings.isProviderEnabled("local")
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val availablePlugins = pluginService.getPluginsWithState(
type = PluginType.ContactSearch,
enabled = true,
)
fun setLocalContacts(enabled: Boolean) {
settings.setProviderEnabled("local", enabled)
val enabledProviders = settings.enabledProviders
fun setProviderEnabled(providerId: String, enabled: Boolean) {
settings.setProviderEnabled(providerId, enabled)
}
}

View File

@ -6,11 +6,8 @@ import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -23,15 +20,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
@ -64,7 +57,6 @@ import coil.compose.AsyncImage
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.themes.atTone
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
@ -109,6 +101,11 @@ fun PluginSettingsScreen(pluginId: String) {
minActiveState = Lifecycle.State.RESUMED
)
val contactPlugins by viewModel.contactPlugins.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
val weatherPlugins by viewModel.weatherPlugins.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
@ -125,6 +122,10 @@ fun PluginSettingsScreen(pluginId: String) {
null
)
val enabledContactPlugins by viewModel.enabledContactPlugins.collectAsStateWithLifecycle(
null
)
val enabledLocationSearchPlugins by viewModel.enabledLocationSearchPlugins.collectAsStateWithLifecycle(
null
)
@ -340,6 +341,59 @@ fun PluginSettingsScreen(pluginId: String) {
}
}
}
if (contactPlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_contacts),
iconPadding = false,
) {
for (plugin in contactPlugins) {
val state = plugin.state
if (state is PluginState.SetupRequired) {
Banner(
modifier = Modifier.padding(16.dp),
text = state.message
?: stringResource(R.string.plugin_state_setup_required),
icon = Icons.Rounded.Info,
primaryAction = {
TextButton(onClick = {
try {
state.setupActivity.sendWithBackgroundPermission(
context
)
} catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e)
}
}) {
Text(stringResource(R.string.plugin_action_setup))
}
}
)
} else if (state is PluginState.Error) {
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.plugin_state_error),
icon = Icons.Rounded.Error,
color = MaterialTheme.colorScheme.errorContainer,
)
}
SwitchPreference(
title = plugin.plugin.label,
enabled = enabledContactPlugins != null && state is PluginState.Ready,
summary = (state as? PluginState.Ready)?.text
?: (state as? PluginState.SetupRequired)?.message
?: plugin.plugin.description,
value = enabledContactPlugins?.contains(plugin.plugin.authority) == true && state is PluginState.Ready,
onValueChanged = {
viewModel.setContactPluginEnabled(
plugin.plugin.authority,
it
)
},
iconPadding = false,
)
}
}
}
if (locationPlugins.isNotEmpty()) {
PreferenceCategory(
stringResource(R.string.plugin_type_locationsearch),

View File

@ -17,6 +17,7 @@ import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import de.mm20.launcher2.plugins.PluginWithState
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import de.mm20.launcher2.preferences.search.ContactSearchSettings
import de.mm20.launcher2.preferences.search.FileSearchSettings
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.preferences.weather.WeatherSettings
@ -39,6 +40,7 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
private val fileSearchSettings: FileSearchSettings by inject()
private val locationSearchSettings: LocationSearchSettings by inject()
private val calendarSearchSettings: CalendarSearchSettings by inject()
private val contactSearchSettings: ContactSearchSettings by inject()
private val weatherSettings: WeatherSettings by inject()
private var pluginPackageName = MutableStateFlow<String?>(null)
@ -90,6 +92,11 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
it.filter { it.plugin.type == PluginType.Calendar }
}
val contactPlugins = states
.map {
it.filter { it.plugin.type == PluginType.ContactSearch }
}
val weatherPlugins = states
.map {
it.filter { it.plugin.type == PluginType.Weather }
@ -127,6 +134,11 @@ class PluginSettingsScreenVM : ViewModel(), KoinComponent {
fileSearchSettings.setPluginEnabled(authority, enabled)
}
val enabledContactPlugins = contactSearchSettings.enabledPlugins
fun setContactPluginEnabled(authority: String, enabled: Boolean) {
contactSearchSettings.setPluginEnabled(authority, enabled)
}
val enabledLocationSearchPlugins = locationSearchSettings.enabledPlugins
fun setLocationSearchPluginEnabled(authority: String, enabled: Boolean) {
locationSearchSettings.setPluginEnabled(authority, enabled)

View File

@ -59,9 +59,10 @@ fun SearchSettingsScreen() {
var showFilterEditor by remember { mutableStateOf(false) }
val plugins by viewModel.plugins.collectAsStateWithLifecycle(emptyList())
val hasCalendarPlugins by remember { derivedStateOf { plugins.any { it.plugin.type == PluginType.Calendar } } }
val hasLocationPlugins by remember { derivedStateOf { plugins.any { it.plugin.type == PluginType.LocationSearch } } }
val plugins by viewModel.plugins.collectAsStateWithLifecycle(null)
val hasCalendarPlugins by remember { derivedStateOf { plugins?.any { it.plugin.type == PluginType.Calendar } } }
val hasLocationPlugins by remember { derivedStateOf { plugins?.any { it.plugin.type == PluginType.LocationSearch } } }
val hasContactPlugins by remember { derivedStateOf { plugins?.any { it.plugin.type == PluginType.ContactSearch } } }
val hasAppShortcutsPermission by viewModel.hasAppShortcutPermission.collectAsStateWithLifecycle(null)
@ -110,30 +111,41 @@ fun SearchSettingsScreen() {
}
)
AnimatedVisibility(hasContactsPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_contact_search_settings),
if (hasLocationPlugins != false) {
Preference(
title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary),
icon = Icons.Rounded.Person,
onClick = {
viewModel.requestContactsPermission(context as AppCompatActivity)
navController?.navigate("settings/search/contacts")
},
modifier = Modifier.padding(16.dp)
)
} else {
AnimatedVisibility(hasContactsPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_contact_search_settings),
onClick = {
viewModel.requestContactsPermission(context as AppCompatActivity)
},
modifier = Modifier.padding(16.dp)
)
}
PreferenceWithSwitch(
title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary),
icon = Icons.Rounded.Person,
switchValue = contacts == true && hasContactsPermission == true,
onSwitchChanged = {
viewModel.setContacts(it)
},
onClick = {
navController?.navigate("settings/search/contacts")
},
enabled = hasContactsPermission == true
)
}
PreferenceWithSwitch(
title = stringResource(R.string.preference_search_contacts),
summary = stringResource(R.string.preference_search_contacts_summary),
icon = Icons.Rounded.Person,
switchValue = contacts == true && hasContactsPermission == true,
onSwitchChanged = {
viewModel.setContacts(it)
},
onClick = {
navController?.navigate("settings/search/contacts")
},
enabled = hasContactsPermission == true
)
if (hasCalendarPlugins) {
if (hasCalendarPlugins != false) {
Preference(
title = stringResource(R.string.preference_search_calendar),
summary = stringResource(R.string.preference_search_calendar_summary),
@ -247,7 +259,7 @@ fun SearchSettingsScreen() {
)
}
if (hasLocationPlugins) {
if (hasLocationPlugins != false) {
Preference(
title = stringResource(R.string.preference_search_locations),
summary = stringResource(R.string.preference_search_locations_summary),

View File

@ -158,5 +158,5 @@ class SearchSettingsScreenVM : ViewModel(), KoinComponent {
}
val plugins = pluginService.getPluginsWithState(enabled = true)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
}

View File

@ -849,7 +849,8 @@
<string name="plugin_state_error">This plugin isn\'t working correctly</string>
<string name="plugin_state_setup_required">You need to setup this plugin first</string>
<string name="plugin_action_setup">Set up</string>
<string name="plugin_type_filesearch">File search</string>
<string name="plugin_type_filesearch">Files</string>
<string name="plugin_type_contacts">Contacts</string>
<string name="plugin_type_weather">Weather provider</string>
<string name="plugin_weather_provider_enable">Set as weather provider</string>
<string name="plugin_weather_provider_enabled">Currently set as weather provider</string>

View File

@ -22,6 +22,13 @@ class ContactSearchSettings internal constructor(private val dataStore: Launcher
}
}
val enabledPlugins: Flow<Set<String>>
get() = dataStore.data.map { it.contactSearchProviders - "local" }
fun setPluginEnabled(authority: String, enabled: Boolean) {
setProviderEnabled(authority, enabled)
}
val callOnTap: Flow<Boolean>
get() = dataStore.data.map { it.contactSearchCallOnTap }.distinctUntilChanged()

View File

@ -23,7 +23,7 @@ data class PostalAddress(
)
/**
* Custom contact channel, for example, WhatsApp message, Telegram video call, etc.
* Custom contact action, for example, WhatsApp message, Telegram video call, etc.
*/
@Serializable
data class CustomContactAction(
@ -35,18 +35,33 @@ data class CustomContactAction(
/**
* Type that is passed to the Intent.
*/
val mimeType: String,
val mimeType: String? = null,
/**
* Package name of the app that handles this channel.
* Used to get the app icon, and label, and to group channels by app.
* If the app is not installed, the channel will be ignored.
* Used to get the app icon, and label, and to group actions by app.
* If the app is not installed, the action will be ignored.
*/
val packageName: String,
)
enum class ContactInfoType {
/**
* Home or private number/address.
*/
Home,
/**
* Cell phone number, only applicable for phone numbers.
*/
Mobile,
/**
* Work or business number/address.
*/
Work,
/**
* Other
*/
Other,
}

View File

@ -2,6 +2,7 @@ package de.mm20.launcher2.contacts
import android.content.Context
import de.mm20.launcher2.contacts.providers.AndroidContactProvider
import de.mm20.launcher2.contacts.providers.PluginContactProvider
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.ContactSearchSettings
@ -36,7 +37,7 @@ internal class ContactRepository(
val providers = providerIds.mapNotNull {
when (it) {
"local" -> if (perm) AndroidContactProvider(context) else null
else -> null
else -> PluginContactProvider(context, it)
}
}

View File

@ -6,7 +6,7 @@ minSdk = "26"
compileSdk = "35"
targetSdk = "35"
pluginSdk = "2.1.2"
pluginSdk = "2.2.0-SNAPSHOT"
gradle = "8.1.2"
android-gradle-plugin = "8.6.0"