Group plugins by package

This commit is contained in:
MM20
2023-11-25 16:29:20 +01:00
parent 6c311fc947
commit 25cd9b707e
17 changed files with 728 additions and 101 deletions
@@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -80,9 +81,6 @@ fun PreferenceScreen(
content: LazyListScope.() -> Unit,
) {
val navController = LocalNavController.current
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.surface)
systemUiController.setNavigationBarColor(Color.Black)
val context = LocalContext.current
@@ -124,7 +122,7 @@ fun PreferenceScreen(
activity?.onBackPressed()
}
}) {
Icon(imageVector = Icons.Rounded.ArrowBack, contentDescription = "Back")
Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
actions = {
@@ -1,17 +1,21 @@
package de.mm20.launcher2.ui.settings
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -21,6 +25,7 @@ import de.mm20.launcher2.licenses.AppLicense
import de.mm20.launcher2.licenses.OpenSourceLicenses
import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.locals.LocalNavController
import de.mm20.launcher2.ui.locals.LocalWallpaperColors
import de.mm20.launcher2.ui.overlays.OverlayHost
@@ -47,6 +52,7 @@ import de.mm20.launcher2.ui.settings.license.LicenseScreen
import de.mm20.launcher2.ui.settings.log.LogScreen
import de.mm20.launcher2.ui.settings.main.MainSettingsScreen
import de.mm20.launcher2.ui.settings.media.MediaIntegrationSettingsScreen
import de.mm20.launcher2.ui.settings.plugins.PluginSettingsScreen
import de.mm20.launcher2.ui.settings.plugins.PluginsSettingsScreen
import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen
import de.mm20.launcher2.ui.settings.searchactions.SearchActionsSettingsScreen
@@ -79,6 +85,15 @@ class SettingsActivity : BaseActivity() {
) {
ProvideSettings {
LauncherTheme {
val systemBarColor = MaterialTheme.colorScheme.surfaceDim
val systemBarColorAlt = MaterialTheme.colorScheme.onSurface
val isDarkTheme = LocalDarkTheme.current
LaunchedEffect(isDarkTheme, systemBarColor, systemBarColorAlt) {
enableEdgeToEdge(
if (isDarkTheme) SystemBarStyle.dark(systemBarColor.toArgb())
else SystemBarStyle.light(systemBarColor.toArgb(), systemBarColorAlt.toArgb())
)
}
OverlayHost {
NavHost(
navController = navController,
@@ -167,6 +182,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/plugins") {
PluginsSettingsScreen()
}
composable("settings/plugins/{id}") {
PluginSettingsScreen(it.arguments?.getString("id") ?: return@composable)
}
composable("settings/about") {
AboutSettingsScreen()
}
@@ -0,0 +1,256 @@
package de.mm20.launcher2.ui.settings.plugins
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
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.rounded.Delete
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun PluginSettingsScreen(pluginId: String) {
val navController = LocalNavController.current
val activity = LocalContext.current as AppCompatActivity
val context = LocalContext.current
val viewModel: PluginSettingsScreenVM = viewModel()
LaunchedEffect(pluginId) {
viewModel.init(pluginId)
}
val pluginPackage by viewModel.pluginPackage.collectAsStateWithLifecycle(null)
val icon by viewModel.icon.collectAsStateWithLifecycle(null)
val types by viewModel.types.collectAsStateWithLifecycle(emptyList())
val states by viewModel.states.collectAsStateWithLifecycle(
emptyList(),
minActiveState = Lifecycle.State.RESUMED
)
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = {
if (navController?.navigateUp() != true) {
activity.onBackPressed()
}
}) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
if (pluginPackage?.settings != null) {
IconButton(onClick = {
pluginPackage?.settings?.let {
activity.startActivity(it)
}
}) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
)
}
}
IconButton(onClick = {
viewModel.openAppInfo(context)
}) {
Icon(
imageVector = Icons.Rounded.Info,
contentDescription = null
)
}
IconButton(onClick = {
viewModel.uninstall(context)
navController?.navigateUp()
}) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = null
)
}
}
)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
) {
Surface(
modifier = Modifier.fillMaxWidth()
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp)
) {
AsyncImage(
model = icon,
contentDescription = null,
modifier = Modifier
.padding(end = 12.dp)
.size(48.dp)
)
Column {
Text(
pluginPackage?.label ?: "",
style = MaterialTheme.typography.titleLarge
)
if (pluginPackage?.isOfficial == true) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 4.dp)
.background(
MaterialTheme.colorScheme.secondary,
shape = MaterialTheme.shapes.medium,
)
.padding(4.dp)
) {
Text(
"Official",
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondary,
)
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondary,
)
}
} else if (pluginPackage?.author != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 4.dp)
) {
Text(
pluginPackage!!.author!!,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
pluginPackage?.description?.let {
Text(
text = it,
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 16.dp,
bottom = 24.dp
),
style = MaterialTheme.typography.bodyMedium,
)
}
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(bottom = 24.dp, start = 12.dp, end = 12.dp)
) {
for (type in types) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(end = 4.dp)
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.medium,
)
.padding(4.dp)
) {
Icon(
when (type) {
PluginType.FileSearch -> Icons.AutoMirrored.Rounded.InsertDriveFile
},
null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
when (type) {
PluginType.FileSearch -> "File search"
},
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
}
val surfaceColor by animateColorAsState(
if (pluginPackage?.enabled == true) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainer
}
)
Surface(
modifier = Modifier
.fillMaxWidth(),
color = surfaceColor,
) {
SwitchPreference(
enabled = pluginPackage != null,
iconPadding = false,
title = "Enable plugin",
value = pluginPackage?.enabled == true,
onValueChanged = {
viewModel.setPluginEnabled(it)
}
)
}
AnimatedVisibility(pluginPackage?.enabled == true) {
PreferenceCategory {
}
}
}
}
}
@@ -0,0 +1,81 @@
package de.mm20.launcher2.ui.settings.plugins
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.provider.Settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.plugins.PluginService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class PluginSettingsScreenVM : ViewModel(), KoinComponent {
private val pluginService by inject<PluginService>()
private var pluginPackageName = MutableStateFlow<String?>(null)
val pluginPackage: StateFlow<PluginPackage?> = pluginPackageName.flatMapLatest {
if (it == null) {
emptyFlow()
} else {
pluginService.getPluginPackage(it)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(100), null)
val icon: Flow<Drawable?> = pluginPackage
.distinctUntilChangedBy { it?.packageName }
.map {
it?.let { pluginService.getPluginPackageIcon(it) }
}
val types: Flow<List<PluginType>> = pluginPackage.map {
it?.plugins?.map { it.type }?.distinct() ?: emptyList()
}
val states: Flow<List<PluginState?>> = pluginPackage.map {
it?.plugins?.map {
pluginService.getPluginState(it)
} ?: emptyList()
}
fun init(pluginId: String) {
this.pluginPackageName.value = pluginId
}
fun setPluginEnabled(enabled: Boolean) {
val plugin = pluginPackage.value ?: return
if (enabled) {
pluginService.enablePluginPackage(plugin)
} else {
pluginService.disablePluginPackage(plugin)
}
}
fun openAppInfo(context: Context) {
val plugin = pluginPackage.value ?: return
context.tryStartActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${plugin.packageName}")
})
}
fun uninstall(context: Context) {
val plugin = pluginPackage.value ?: return
pluginService.uninstallPluginPackage(context, plugin)
}
}
@@ -2,15 +2,16 @@ package de.mm20.launcher2.ui.settings.plugins
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.ExtensionOff
import androidx.compose.material.icons.rounded.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -23,19 +24,25 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.locals.LocalNavController
@Composable
fun PluginsSettingsScreen() {
val viewModel: PluginsSettingsScreenVM = viewModel()
val navController = LocalNavController.current
val hostInstalled by viewModel.hostInstalled.collectAsState(null)
val hasPermission by viewModel.hasPermission.collectAsState(null)
val context = LocalContext.current
val plugins by viewModel.plugins.collectAsState(null)
val pluginPackages by viewModel.pluginPackages.collectAsState(null)
val enabledPackages by viewModel.enabledPluginPackages.collectAsState(emptyList())
val disabledPackages by viewModel.disabledPluginPackages.collectAsState(emptyList())
PreferenceScreen(title = stringResource(R.string.preference_screen_plugins)) {
when {
hostInstalled == false -> {
@@ -73,7 +80,7 @@ fun PluginsSettingsScreen() {
}
}
plugins?.isEmpty() == true -> {
pluginPackages?.isEmpty() == true -> {
item {
Column(
modifier = Modifier
@@ -92,28 +99,61 @@ fun PluginsSettingsScreen() {
}
}
plugins != null -> {
items(plugins!!) { item ->
val icon by remember(item.plugin.authority) {
viewModel.getIcon(item.plugin)
}.collectAsState(null)
Preference(
title = { Text(item.plugin.label) },
summary = item.plugin.description?.let { { Text(it) } },
controls = {
Switch(checked = item.plugin.enabled, onCheckedChange = {
viewModel.setPluginEnabled(item.plugin, it)
})
},
icon = {
AsyncImage(model = icon, contentDescription = null, modifier = Modifier.size(36.dp))
},
onClick = {
viewModel.setPluginEnabled(item.plugin, !item.plugin.enabled)
else -> {
if (enabledPackages.isNotEmpty()) {
item {
PreferenceCategory("Enabled") {
for (plugin in enabledPackages) {
PluginPreference(viewModel, plugin)
}
}
)
}
}
if (disabledPackages.isNotEmpty()) {
item {
PreferenceCategory("Installed") {
for (plugin in disabledPackages) {
PluginPreference(viewModel, plugin)
}
}
}
}
}
}
}
}
@Composable
private fun PluginPreference(viewModel: PluginsSettingsScreenVM, plugin: PluginPackage) {
val navController = LocalNavController.current
val icon by remember(plugin.packageName) {
viewModel.getIcon(plugin)
}.collectAsState(null)
Preference(
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(plugin.label)
if (plugin.isOfficial) {
Icon(
Icons.Rounded.Verified, null,
modifier = Modifier.padding(start = 4.dp).size(16.dp),
tint = MaterialTheme.colorScheme.secondary,
)
}
}
},
summary = plugin.description?.let { { Text(it) } },
icon = {
AsyncImage(
model = icon,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
onClick = {
navController?.navigate("settings/plugins/${plugin.packageName}")
}
)
}
@@ -3,13 +3,16 @@ package de.mm20.launcher2.ui.settings.plugins
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.ktx.normalize
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.Plugin
import de.mm20.launcher2.plugin.PluginPackage
import de.mm20.launcher2.plugins.PluginService
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.shareIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -20,23 +23,23 @@ class PluginsSettingsScreenVM : ViewModel(), KoinComponent {
val hostInstalled = pluginService.isPluginHostInstalled()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Plugins)
val plugins = hasPermission.flatMapLatest {
if (it) pluginService.getPluginsWithState() else emptyFlow()
val pluginPackages = pluginService
.getPluginPackages()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100), 1)
val enabledPluginPackages = pluginPackages.mapLatest {
it.filter { it.enabled }.sortedBy { it.label }
}
fun setPluginEnabled(plugin: Plugin, value: Boolean) {
if (value) {
pluginService.enablePlugin(plugin)
} else {
pluginService.disablePlugin(plugin)
}
val disabledPluginPackages = pluginPackages.mapLatest {
it.filter { !it.enabled }.sortedBy { it.label }
}
fun requestPermission(context: Context) {
permissionsManager.requestPermission(context as AppCompatActivity, PermissionGroup.Plugins)
}
fun getIcon(plugin: Plugin) = flow {
emit(pluginService.getPluginIcon(plugin))
fun getIcon(plugin: PluginPackage) = flow {
emit(pluginService.getPluginPackageIcon(plugin))
}
}