diff --git a/app/build.gradle b/app/build.gradle index b1043462..bbebc4d7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,7 +33,6 @@ android { } } buildFeatures { - viewBinding true // Required by ChooseActivityDialog (pending Compose migration) compose true buildConfig true } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b6f19dd5..0900c0f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,7 +60,7 @@ android:excludeFromRecents="true" android:noHistory="true" android:taskAffinity="" - android:theme="@style/AppTheme.Dialog.NoActionBar" /> + android:theme="@style/AppTheme.Translucent" /> navigator.navigate(WidgetProfileEditorRoute(profile.id)) + }, + onProfileCreated = { profile -> + navigator.navigate(WidgetProfileEditorRoute(profile.id, isNew = true)) } ) } @@ -68,7 +71,8 @@ fun DevDrawerHost( } entry { key -> WidgetProfileEditor( - profileId = key.id + profileId = key.id, + isNew = key.isNew ) } } diff --git a/app/src/main/java/de/psdev/devdrawer/Routes.kt b/app/src/main/java/de/psdev/devdrawer/Routes.kt index bdf3f32d..d074af4a 100644 --- a/app/src/main/java/de/psdev/devdrawer/Routes.kt +++ b/app/src/main/java/de/psdev/devdrawer/Routes.kt @@ -22,4 +22,4 @@ data class WidgetEditorRoute(val id: Int) : NavKey data class WidgetSetupRoute(val widgetId: Int) : NavKey @Serializable -data class WidgetProfileEditorRoute(val id: String) : NavKey +data class WidgetProfileEditorRoute(val id: String, val isNew: Boolean = false) : NavKey diff --git a/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt b/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt index a9c543f9..fe361468 100644 --- a/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/about/AboutScreen.kt @@ -1,8 +1,10 @@ package de.psdev.devdrawer.about +import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent +import android.widget.Toast import androidx.browser.customtabs.CustomTabsIntent -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,16 +21,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import de.psdev.devdrawer.BuildConfig import de.psdev.devdrawer.R +import de.psdev.devdrawer.ui.AppIcon @Composable fun AboutScreen( @@ -48,11 +49,9 @@ fun AboutScreen( } @Composable -private fun AboutHeader() { +internal fun AboutHeader() { val context = LocalContext.current - val appIcon = remember(context) { - context.packageManager.getApplicationIcon(context.packageName).toBitmap().asImageBitmap() - } + val appIcon = remember(context) { context.applicationInfo.loadIcon(context.packageManager) } Column( modifier = Modifier .fillMaxWidth() @@ -60,40 +59,48 @@ private fun AboutHeader() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Image( - bitmap = appIcon, - contentDescription = null, - modifier = Modifier.size(96.dp) - ) + AppIcon(icon = appIcon, size = 96.dp) Text( text = stringResource(id = R.string.app_name), style = MaterialTheme.typography.headlineMedium ) Text( - text = "Version ${BuildConfig.VERSION_NAME}", + text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME), style = MaterialTheme.typography.bodyLarge ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) { - Button(onClick = { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = "https://play.google.com/store/apps/details?id=de.psdev.devdrawer".toUri() - setPackage("com.android.vending") - } - context.startActivity(intent) - }) { - Text("Play Store") + Button(onClick = { openPlayStore(context) }) { + Text(stringResource(R.string.about_play_store)) } - OutlinedButton(onClick = { - val customTabsIntent = CustomTabsIntent.Builder().build() - customTabsIntent.intent.data = "https://github.com/PSDev/DevDrawer".toUri() - customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(customTabsIntent.intent) - }) { - Text("GitHub") + OutlinedButton(onClick = { openFirst(context, CustomTabsIntent.Builder().build().intent.setData(SOURCE_URL.toUri())) }) { + Text(stringResource(R.string.about_github)) } } } } + +private const val PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=de.psdev.devdrawer" +private const val SOURCE_URL = "https://github.com/PSDev/DevDrawer" + +/** The Play Store app if installed, otherwise its web page in a browser. */ +private fun openPlayStore(context: Context) = openFirst( + context, + Intent(Intent.ACTION_VIEW, PLAY_STORE_URL.toUri()).setPackage("com.android.vending"), + Intent(Intent.ACTION_VIEW, PLAY_STORE_URL.toUri()) +) + +/** Starts the first intent something can handle, or says the device can't. */ +private fun openFirst(context: Context, vararg intents: Intent) { + for (intent in intents) { + try { + context.startActivity(intent) + return + } catch (e: ActivityNotFoundException) { + // Try the next one. + } + } + Toast.makeText(context, R.string.action_unavailable, Toast.LENGTH_LONG).show() +} diff --git a/app/src/main/java/de/psdev/devdrawer/adapters/ActivityListAdapter.kt b/app/src/main/java/de/psdev/devdrawer/adapters/ActivityListAdapter.kt deleted file mode 100644 index d9915d79..00000000 --- a/app/src/main/java/de/psdev/devdrawer/adapters/ActivityListAdapter.kt +++ /dev/null @@ -1,53 +0,0 @@ -package de.psdev.devdrawer.adapters - -import android.app.Activity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.TextView -import de.psdev.devdrawer.R - -class ActivityListAdapter(activity: Activity, private val activityList: List): BaseAdapter() { - - private val layoutInflater: LayoutInflater = activity.layoutInflater - - // ========================================================================================================================== - // BaseAdapter - // ========================================================================================================================== - - override fun getCount(): Int = activityList.size - - override fun getItem(position: Int): String = activityList[position] - - override fun getItemId(position: Int): Long = activityList[position].hashCode().toLong() - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView ?: createView(parent) - bindView(position, view) - return view - } - - private fun createView(parent: ViewGroup): View { - return layoutInflater.inflate(R.layout.activity_choice_list_item, parent, false).apply { - tag = ViewHolder(this) - } - } - - private fun bindView(position: Int, view: View) { - val viewHolder = view.tag as ViewHolder - viewHolder.run { - txtActivityPath.text = activityList[position].substring(0, activityList[position].lastIndexOf('.')) - txtActivityName.text = activityList[position].substring(activityList[position].lastIndexOf('.'), activityList[position].length) - } - } - - // ========================================================================================================================== - // Inner classes - // ========================================================================================================================== - - class ViewHolder(view: View) { - val txtActivityPath: TextView = view.findViewById(R.id.activityPathTextView) - val txtActivityName: TextView = view.findViewById(R.id.activityNameTextView) - } -} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt index 2a2a30c0..c510f164 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/AppInfo.kt @@ -20,7 +20,8 @@ data class AppInfo( val lastUpdateTime: Long, val signatureHashSha256: String, val versionName: String = "", - val versionCode: Long = 0 + val versionCode: Long = 0, + val canUninstall: Boolean = true ) fun PackageHashInfo.toAppInfo(context: Context): AppInfo? = try { @@ -43,7 +44,8 @@ fun PackageHashInfo.toAppInfo(context: Context): AppInfo? = try { lastUpdateTime = lastUpdateTime, signatureHashSha256 = signatureHashSha256, versionName = packageInfo.versionName ?: "", - versionCode = versionCode + versionCode = versionCode, + canUninstall = applicationInfo.canUninstall ) } catch (e: Exception) { logger.warn(e) { "Error: ${e.message}" } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/AppScreens.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/AppScreens.kt new file mode 100644 index 00000000..01a1b02f --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/AppScreens.kt @@ -0,0 +1,97 @@ +package de.psdev.devdrawer.appwidget + +import android.content.pm.ActivityInfo +import android.graphics.drawable.Drawable +import androidx.compose.foundation.clickable +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.R +import de.psdev.devdrawer.ui.AppIcon + +/** One screen of an app that can be opened directly. */ +data class AppScreen( + val className: String, + val label: String, + /** The class name relative to the app's package, like ".settings.SettingsActivity". */ + val shortName: String, + /** The screen the launcher opens. */ + val isMain: Boolean +) + +/** + * The screens of [packageName] that another app may open: exported, enabled and not behind a permission. + * The launcher's screen comes first, then the rest by label. + */ +fun appScreens( + packageName: String, + activities: List, + mainClassName: String?, + labelOf: (ActivityInfo) -> String +): List = activities + .filter { it.exported && it.enabled && it.permission == null } + .map { activity -> + AppScreen( + className = activity.name, + label = labelOf(activity), + shortName = if (activity.name.startsWith("$packageName.")) activity.name.removePrefix(packageName) else activity.name, + isMain = activity.name == mainClassName + ) + } + .sortedWith(compareByDescending { it.isMain }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.label }) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChooseScreenSheet( + appName: String, + appIcon: Drawable?, + screens: List, + onScreenSelected: (AppScreen) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AppIcon(icon = appIcon, size = 40.dp) + Text(text = stringResource(R.string.open_app_screen, appName), style = MaterialTheme.typography.titleLarge) + } + LazyColumn(modifier = Modifier.padding(vertical = 8.dp)) { + items(screens, key = { it.className }) { screen -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onScreenSelected(screen) } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = screen.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (screen.isMain) FontWeight.Medium else null + ) + Text( + text = if (screen.isMain) stringResource(R.string.main_screen, screen.shortName) else screen.shortName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/ChooseActivityDialog.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/ChooseActivityDialog.kt index aeacc3fb..b0be11e9 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/ChooseActivityDialog.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/ChooseActivityDialog.kt @@ -1,22 +1,22 @@ package de.psdev.devdrawer.appwidget -import android.app.Activity +import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle -import android.view.View -import android.widget.AdapterView import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import de.psdev.devdrawer.R -import de.psdev.devdrawer.adapters.ActivityListAdapter -import de.psdev.devdrawer.databinding.ActivityChoiceBinding +import de.psdev.devdrawer.ui.theme.DevDrawerTheme import mu.KLogging -class ChooseActivityDialog: Activity(), AdapterView.OnItemClickListener { +/** Lets the user pick which screen of an app to open from the widget; opens it directly when there is only one. */ +class ChooseActivityDialog : ComponentActivity() { - companion object: KLogging() { + companion object : KLogging() { const val EXTRA_PACKAGE_NAME = "packageName" @JvmStatic @@ -25,57 +25,60 @@ class ChooseActivityDialog: Activity(), AdapterView.OnItemClickListener { } } - private lateinit var binding: ActivityChoiceBinding - private val appPackageName: String by lazy { intent.getStringExtra(EXTRA_PACKAGE_NAME)!! } - private lateinit var activitiesList: List - - // ========================================================================================================================== - // Android Lifecycle - // ========================================================================================================================== - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityChoiceBinding.inflate(layoutInflater) - setContentView(binding.root) + val appPackageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) + val screens = appPackageName?.let(::screensOf) + when { + appPackageName == null || screens == null -> finish() + screens.isEmpty() -> { + Toast.makeText(this, R.string.no_activities, Toast.LENGTH_LONG).show() + finish() + } + screens.size == 1 -> open(appPackageName, screens.single()) + else -> { + val appInfo = packageManager.getApplicationInfo(appPackageName, 0) + val appName = appInfo.loadLabel(packageManager).toString() + val appIcon = appInfo.loadIcon(packageManager) + setContent { + DevDrawerTheme { + ChooseScreenSheet( + appName = appName, + appIcon = appIcon, + screens = screens, + onScreenSelected = { open(appPackageName, it) }, + onDismiss = ::finish + ) + } + } + } + } } - override fun onContentChanged() { - super.onContentChanged() - try { - activitiesList = getActivityList(appPackageName) - } catch (e: PackageManager.NameNotFoundException) { - finish() + private fun screensOf(appPackageName: String): List? = try { + val packageInfo = packageManager.getPackageInfo(appPackageName, PackageManager.GET_ACTIVITIES) + val mainClassName = packageManager.getLaunchIntentForPackage(appPackageName)?.component?.className + appScreens(appPackageName, packageInfo.activities.orEmpty().toList(), mainClassName) { + it.loadLabel(packageManager).toString() } - if (activitiesList.isEmpty()) { - Toast.makeText(this, R.string.no_activities, Toast.LENGTH_LONG).show() - finish() - } - binding.listView.adapter = ActivityListAdapter(this, activitiesList) - binding.listView.onItemClickListener = this + } catch (e: PackageManager.NameNotFoundException) { + null } - // ========================================================================================================================== - // AdapterView.OnItemClickListener - // ========================================================================================================================== - - override fun onItemClick(adapterView: AdapterView<*>, view: View, i: Int, l: Long) { - val intent = Intent() - intent.component = ComponentName(appPackageName, activitiesList[i]) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - startActivity(intent) + private fun open(appPackageName: String, screen: AppScreen) { + val intent = Intent().apply { + component = ComponentName(appPackageName, screen.className) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + logger.warn(e) { "Cannot open ${screen.className}" } + Toast.makeText(this, R.string.screen_open_failed, Toast.LENGTH_LONG).show() + } catch (e: SecurityException) { + logger.warn(e) { "Not allowed to open ${screen.className}" } + Toast.makeText(this, R.string.screen_open_failed, Toast.LENGTH_LONG).show() + } finish() } - - // ========================================================================================================================== - // Private API - // ========================================================================================================================== - - @Throws(PackageManager.NameNotFoundException::class) - private fun getActivityList(packageName: String): List { - val packageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES) - return packageInfo.activities.orEmpty() - .filter { it.exported } - .map { it.name } - } - } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt index 3567330a..1375d3f0 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/ClickHandlingActivity.kt @@ -1,23 +1,17 @@ package de.psdev.devdrawer.appwidget -import android.content.ActivityNotFoundException -import android.content.Intent import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.net.Uri import android.os.Bundle -import android.provider.Settings import androidx.fragment.app.FragmentActivity import dagger.hilt.android.AndroidEntryPoint import de.psdev.devdrawer.R import de.psdev.devdrawer.settings.PreferenceKeys -import de.psdev.devdrawer.utils.Constants -import mu.KLogging import javax.inject.Inject +/** Trampoline for taps on widget rows; the widget can't start these actions itself. */ @AndroidEntryPoint class ClickHandlingActivity : FragmentActivity() { - companion object : KLogging() { + companion object { const val EXTRA_PACKAGE_NAME = "packageName" const val EXTRA_LAUNCH_TYPE = "launchType" } @@ -25,88 +19,16 @@ class ClickHandlingActivity : FragmentActivity() { @Inject lateinit var sharedPreferences: SharedPreferences - // ========================================================================================================================== - // Android Lifecycle - // ========================================================================================================================== - public override fun onCreate(state: Bundle?) { super.onCreate(state) - val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) - val launchType = intent.getIntExtra(EXTRA_LAUNCH_TYPE, 0) - - if (packageName != null && isAppInstalled(packageName)) { - when (launchType) { - Constants.LAUNCH_APP -> startApp(packageName) - Constants.LAUNCH_APP_DETAILS -> startAppDetails(packageName) - Constants.LAUNCH_UNINSTALL -> startUninstall(packageName) - } - } - // We need to always call finish - finish() - } - - // ========================================================================================================================== - // Private API - // ========================================================================================================================== - - private fun isAppInstalled(uri: String): Boolean = try { - packageManager.getPackageInfo(uri, PackageManager.GET_ACTIVITIES) - true - } catch (e: PackageManager.NameNotFoundException) { - false - } - - private fun startApp(packageName: String) { - if (sharedPreferences.getBoolean( + if (packageName != null) { + val askForScreen = sharedPreferences.getBoolean( PreferenceKeys.SHOW_ACTIVITY_CHOICE, resources.getBoolean(R.bool.pref_show_activity_choice_default) ) - ) { - // Show the activity choice dialog - showActivityChooser(packageName) - } else { - // Launch the app - try { - val intent = packageManager.getLaunchIntentForPackage(packageName)!!.apply { - addCategory(Intent.CATEGORY_LAUNCHER) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - } - startActivity(intent) - } catch (e: NullPointerException) { - showActivityChooser(packageName) - } - } - } - - private fun showActivityChooser(packageName: String) { - val intent = ChooseActivityDialog.createStartIntent(this, packageName).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - startActivity(intent) - } - - private fun startAppDetails(packageName: String) { - // Launch the app details settings screen for the app - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + packageName)).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - addCategory(Intent.CATEGORY_DEFAULT) + WidgetTapHandler(this).handle(packageName, intent.getIntExtra(EXTRA_LAUNCH_TYPE, 0), askForScreen) } - startActivity(intent) - } - - @Suppress("DEPRECATION") - private fun startUninstall(packageName: String) { - try { - val packageUri = Uri.parse("package:$packageName") - val uninstallIntent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - startActivity(uninstallIntent) - } catch (e: ActivityNotFoundException) { - logger.warn { "Application cannot be uninstalled / possibly system app" } - } - + finish() } - } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt index 187d853c..3fec0a05 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/PackageInfo.kt @@ -12,4 +12,8 @@ data class PackageHashInfo( fun PackageInfo.toPackageHashInfo(): PackageHashInfo = PackageHashInfo(packageName, firstInstallTime, lastUpdateTime, signatureHashSha256) val PackageInfo.isSystemApp: Boolean - get() = applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) != 0 \ No newline at end of file + get() = applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) != 0 + +/** Regular apps can be uninstalled; of system apps only updated ones, which then lose their updates. */ +val ApplicationInfo.canUninstall: Boolean + get() = flags and ApplicationInfo.FLAG_SYSTEM == 0 || flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetTapHandler.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetTapHandler.kt new file mode 100644 index 00000000..b11d56dd --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetTapHandler.kt @@ -0,0 +1,83 @@ +package de.psdev.devdrawer.appwidget + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.provider.Settings +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.core.net.toUri +import de.psdev.devdrawer.R +import de.psdev.devdrawer.receivers.UpdateReceiver +import de.psdev.devdrawer.utils.Constants +import mu.KLogging + +/** Carries out a tap on a widget row, and tells the user when it can't instead of silently doing nothing. */ +class WidgetTapHandler(private val context: Context) { + + companion object : KLogging() + + fun handle(packageName: String, launchType: Int, askForScreen: Boolean) { + val applicationInfo = applicationInfoOf(packageName) + if (applicationInfo == null) { + // The widget is out of date: say so and reload it without the app. + toast(R.string.app_not_installed) + UpdateReceiver.send(context) + return + } + when (launchType) { + Constants.LAUNCH_APP -> startApp(packageName, askForScreen) + Constants.LAUNCH_APP_DETAILS -> startAppDetails(packageName) + Constants.LAUNCH_UNINSTALL -> if (applicationInfo.canUninstall) startUninstall(packageName) else toast(R.string.system_app_uninstall) + } + } + + private fun applicationInfoOf(packageName: String): ApplicationInfo? = try { + context.packageManager.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + + private fun startApp(packageName: String, askForScreen: Boolean) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (askForScreen || launchIntent == null) { + start(ChooseActivityDialog.createStartIntent(context, packageName).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } else { + start( + launchIntent.addCategory(Intent.CATEGORY_LAUNCHER) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + ) + } + } + + private fun startAppDetails(packageName: String) { + start( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()) + .addCategory(Intent.CATEGORY_DEFAULT) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ) + } + + @Suppress("DEPRECATION") + private fun startUninstall(packageName: String) { + start( + Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$packageName".toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + ) + } + + private fun start(intent: Intent) { + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + logger.warn(e) { "Nothing handles $intent" } + toast(R.string.action_unavailable) + } + } + + private fun toast(@StringRes message: Int) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerGlanceWidget.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerGlanceWidget.kt index 070ae227..f6c3fc85 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerGlanceWidget.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerGlanceWidget.kt @@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.longPreferencesKey import androidx.glance.GlanceId import androidx.glance.GlanceTheme +import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.provideContent @@ -36,6 +37,9 @@ class DevDrawerGlanceWidget : GlanceAppWidget() { override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + /** The layout adapts to the widget's actual size: see [DevDrawerWidgetContent]. */ + override val sizeMode: SizeMode = SizeMode.Exact + override suspend fun provideGlance(context: Context, id: GlanceId) { val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) val loader = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java).widgetContentLoader() @@ -95,7 +99,8 @@ class DevDrawerGlanceWidget : GlanceAppWidget() { private fun AppInfo.toItem(context: Context): WidgetAppItem = WidgetAppItem( name = name, packageName = packageName, - icon = scaledIcon(context) + icon = scaledIcon(context), + canUninstall = canUninstall ) private fun AppInfo.scaledIcon(context: Context): Bitmap? { diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContent.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContent.kt index e7b8ccaa..378d8f56 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContent.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContent.kt @@ -15,6 +15,7 @@ import androidx.glance.GlanceTheme import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.LocalContext +import androidx.glance.LocalSize import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.actionStartActivity @@ -61,14 +62,26 @@ fun DevDrawerWidgetContent(state: WidgetUiState) { } else { background.cornerRadius(16.dp) } + val size = LocalSize.current Column(modifier = rounded) { - Header(state) + if (size.height < SHORT_HEIGHT) { + // About one cell tall: the title, then the apps as a strip of icons. + Header(state, showDetails = false) + if (state.apps.isEmpty()) { + ChooseAppsButton(context, state.appWidgetId, GlanceModifier.fillMaxWidth().padding(8.dp)) + } else { + IconStrip(context, state.apps, maxIcons = ((size.width - 16.dp).value / ICON_STRIP_SLOT.value).toInt()) + } + return@Column + } + Header(state, showDetails = true) if (state.apps.isEmpty()) { EmptyState(context, state.appWidgetId) } else { + val compact = size.width < NARROW_WIDTH LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(state.apps, itemId = { it.packageName.hashCode().toLong() }) { app -> - AppRow(context, app) + AppRow(context, app, compact) } if (state.hiddenAppCount > 0) { item { @@ -84,8 +97,33 @@ fun DevDrawerWidgetContent(state: WidgetUiState) { } } +/** Below this the widget shows its apps as a strip of icons. */ +private val SHORT_HEIGHT = 130.dp + +/** Below this rows drop the package name and the actions. */ +private val NARROW_WIDTH = 250.dp + +private val ICON_STRIP_SLOT = 52.dp + @Composable -private fun Header(state: WidgetUiState) { +private fun IconStrip(context: Context, apps: List, maxIcons: Int) { + Row( + modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + apps.take(maxIcons.coerceAtLeast(1)).forEach { app -> + Image( + provider = app.icon?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.ic_baseline_widgets_24), + contentDescription = context.getString(R.string.open_app_screen, app.name), + modifier = GlanceModifier.size(ICON_STRIP_SLOT).padding(6.dp) + .clickable(clickHandlingAction(app.packageName, Constants.LAUNCH_APP)) + ) + } + } +} + +@Composable +private fun Header(state: WidgetUiState, showDetails: Boolean) { val context = LocalContext.current val light = state.headerColor.light val dark = state.headerColor.dark @@ -107,13 +145,15 @@ private fun Header(state: WidgetUiState) { maxLines = 1, style = TextStyle(color = content, fontSize = 18.sp, fontWeight = FontWeight.Medium) ) - Text( - text = context.getString(R.string.widget_subtitle, count, state.updatedAt), - maxLines = 1, - style = TextStyle(color = content, fontSize = 12.sp) - ) + if (showDetails) { + Text( + text = context.getString(R.string.widget_subtitle, count, state.updatedAt), + maxLines = 1, + style = TextStyle(color = content, fontSize = 12.sp) + ) + } } - Image( + if (showDetails) Image( provider = ImageProvider(R.drawable.ic_baseline_refresh_24), contentDescription = context.getString(R.string.reload), colorFilter = ColorFilter.tint(content), @@ -146,21 +186,27 @@ private fun EmptyState(context: Context, appWidgetId: Int) { style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp) ) Spacer(GlanceModifier.height(12.dp)) - Button( - text = context.getString(R.string.widget_choose_apps), - onClick = actionStartActivity(mainActivityIntent(context, appWidgetId, openSetup = true)), - colors = ButtonDefaults.buttonColors( - backgroundColor = GlanceTheme.colors.primary, - contentColor = GlanceTheme.colors.onPrimary - ) - ) + ChooseAppsButton(context, appWidgetId) } } @Composable -private fun AppRow(context: Context, app: WidgetAppItem) { +private fun ChooseAppsButton(context: Context, appWidgetId: Int, modifier: GlanceModifier = GlanceModifier) { + Button( + modifier = modifier, + text = context.getString(R.string.widget_choose_apps), + onClick = actionStartActivity(mainActivityIntent(context, appWidgetId, openSetup = true)), + colors = ButtonDefaults.buttonColors( + backgroundColor = GlanceTheme.colors.primary, + contentColor = GlanceTheme.colors.onPrimary + ) + ) +} + +@Composable +private fun AppRow(context: Context, app: WidgetAppItem, compact: Boolean) { Row( - modifier = GlanceModifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + modifier = GlanceModifier.fillMaxWidth().padding(start = 16.dp, end = if (compact) 16.dp else 4.dp, top = 8.dp, bottom = 8.dp) .clickable(clickHandlingAction(app.packageName, Constants.LAUNCH_APP)), verticalAlignment = Alignment.CenterVertically ) { @@ -175,19 +221,28 @@ private fun AppRow(context: Context, app: WidgetAppItem) { maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 16.sp) ) - Text( - text = app.packageName, - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp) + if (!compact) { + Text( + text = app.packageName, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp) + ) + } + } + // Narrow widgets show just the app; Uninstall and App details need the wider layout. + if (compact) return@Row + if (app.canUninstall) { + Image( + provider = ImageProvider(R.drawable.ic_baseline_delete_24), + contentDescription = context.getString(R.string.uninstall_app, app.name), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + modifier = GlanceModifier.size(48.dp).padding(12.dp) + .clickable(clickHandlingAction(app.packageName, Constants.LAUNCH_UNINSTALL)) ) + } else { + // Keeps App details aligned with the rows above and below. + Spacer(GlanceModifier.size(48.dp)) } - Image( - provider = ImageProvider(R.drawable.ic_baseline_delete_24), - contentDescription = context.getString(R.string.uninstall_app, app.name), - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(48.dp).padding(12.dp) - .clickable(clickHandlingAction(app.packageName, Constants.LAUNCH_UNINSTALL)) - ) Image( provider = ImageProvider(R.drawable.ic_baseline_info_24), contentDescription = context.getString(R.string.app_details_for, app.name), diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/WidgetUiState.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/WidgetUiState.kt index 5e0ad8e9..9174e0ca 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/WidgetUiState.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/WidgetUiState.kt @@ -18,5 +18,7 @@ data class WidgetAppItem( val name: String, val packageName: String, /** Scaled to the row's icon size so the widget's RemoteViews stay small; null shows a placeholder. */ - val icon: Bitmap? + val icon: Bitmap?, + /** False for system apps, which only offer app details. */ + val canUninstall: Boolean = true ) diff --git a/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt b/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt index 28501974..2b7130cf 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt @@ -37,6 +37,10 @@ data class PackageFilter( @delegate:Ignore private val filterRegex: Regex? by lazy { runCatching { filter.replace("*", ".*").toRegex() }.getOrNull() } + /** A package name filter that is non-blank and compiles; an invalid one matches nothing. */ + val isValidPattern: Boolean + get() = type == FilterType.PACKAGE_NAME && filter.isNotBlank() && filterRegex != null + fun matches(packageHashInfo: PackageHashInfo): Boolean = when (type) { FilterType.PACKAGE_NAME -> filterRegex?.matches(packageHashInfo.packageName) == true FilterType.SIGNATURE -> filter == packageHashInfo.signatureHashSha256 diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt index e66bdb54..b11deb0d 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt @@ -16,13 +16,15 @@ import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.ui.UiState import de.psdev.devdrawer.widgets.IWidgetRepository +import java.util.UUID +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.UUID -import javax.inject.Inject /** A profile as the Profiles list shows it. */ @Immutable @@ -60,7 +62,7 @@ class WidgetProfilesViewModel @Inject constructor( } } - val viewState = combine( + val viewState: StateFlow>> = combine( widgetProfileRepository.widgetProfilesFlow(), packageFilterRepository.allFiltersFlow(), widgetRepository.widgetsFlow(), @@ -68,7 +70,7 @@ class WidgetProfilesViewModel @Inject constructor( ) { profiles, filters, widgets, packages -> val filtersByProfile = filters.groupBy { it.profileId } val widgetsByProfile = widgets.groupBy { it.profileId } - UiState.Success( + val summaries: UiState> = UiState.Success( profiles.map { profile -> val profileFilters = filtersByProfile[profile.id].orEmpty() ProfileSummary( @@ -79,7 +81,9 @@ class WidgetProfilesViewModel @Inject constructor( ) } ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) + summaries + }.catch { emit(UiState.Error(it)) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) /** Deletes [widgetProfile] unless widgets use it; the result says which happened. */ fun deleteProfile(widgetProfile: WidgetProfile, onResult: (DeleteResult) -> Unit) { @@ -118,7 +122,7 @@ class WidgetProfilesViewModel @Inject constructor( fun createNewProfile(onCreated: (WidgetProfile) -> Unit) { viewModelScope.launch { val size = widgetProfileRepository.findAll().size - val widgetProfile = WidgetProfile(name = "Profile ${size + 1}") + val widgetProfile = WidgetProfile(name = application.getString(R.string.new_profile_name, size + 1)) widgetProfileRepository.create(widgetProfile) trackingService.trackAction(Events.PROFILE_CREATED) onCreated(widgetProfile) diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt deleted file mode 100644 index fed7f656..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialog.kt +++ /dev/null @@ -1,128 +0,0 @@ -package de.psdev.devdrawer.profiles.ui.editor - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.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.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.PackageFilter -import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.Error -import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.Loaded -import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel.ViewState.Loading -import de.psdev.devdrawer.ui.autocomplete.AutoCompleteTextView -import de.psdev.devdrawer.ui.loading.LoadingView -import de.psdev.devdrawer.ui.theme.DevDrawerTheme - -@Composable -fun AddPackageNamePackageFilterDialog( - currentFilters: List, - viewModel: AddPackageNamePackageFilterDialogViewModel = hiltViewModel(), - closeDialog: () -> Unit = {}, - addFilter: (String) -> Unit = {} -) { - val viewState by remember(viewModel) { viewModel.availablePackageFilters(currentFilters) } - .collectAsState(initial = Loading) - AddPackageNamePackageFilterDialog( - viewState = viewState, - closeDialog = closeDialog, - addFilter = addFilter - ) -} - -@Composable -internal fun AddPackageNamePackageFilterDialog( - viewState: AddPackageNamePackageFilterDialogViewModel.ViewState, - closeDialog: () -> Unit = {}, - addFilter: (String) -> Unit = {} -) { - Dialog( - onDismissRequest = closeDialog, - properties = DialogProperties(dismissOnClickOutside = false) - ) { - Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.surface, shape = MaterialTheme.shapes.medium) { - Column(modifier = Modifier.padding(8.dp)) { - Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(painter = painterResource(id = R.drawable.ic_certificate), contentDescription = stringResource(id = R.string.app_signature)) - Spacer(modifier = Modifier.size(4.dp)) - Text(modifier = Modifier.weight(1f), text = stringResource(id = R.string.enter_package_name_filter)) - } - Spacer(modifier = Modifier.size(4.dp)) - HorizontalDivider() - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - when (viewState) { - Loading -> LoadingView( - modifier = Modifier - .align(Alignment.Center) - .padding(16.dp), showText = false - ) - is Loaded -> { - Column { - var text by remember { mutableStateOf("") } - AutoCompleteTextView( - options = viewState.data, - label = { Text(text = stringResource(id = R.string.packagefilter)) }, - onTextChanged = { text = it } - ) - Spacer(modifier = Modifier.size(8.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = closeDialog) { - Text(text = stringResource(id = R.string.cancel)) - } - TextButton(onClick = { addFilter(text) }, enabled = text.isNotBlank()) { - Text(text = stringResource(id = R.string.add)) - } - } - } - } - is Error -> Text(text = stringResource(id = R.string.error_message, viewState.message)) - } - } - } - } - } -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun Preview_AddPackageNamePackageFilterDialog() { - DevDrawerTheme { - Surface { - AddPackageNamePackageFilterDialog( - viewState = Loaded( - (1..6).map { "com.example.$it" } - ) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt deleted file mode 100644 index bf9ec008..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddPackageNamePackageFilterDialogViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package de.psdev.devdrawer.profiles.ui.editor - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import de.psdev.devdrawer.apps.AppsService -import de.psdev.devdrawer.database.PackageFilter -import kotlinx.coroutines.flow.flow -import java.text.Collator -import javax.inject.Inject - -@HiltViewModel -class AddPackageNamePackageFilterDialogViewModel @Inject constructor( - private val appsService: AppsService -): ViewModel() { - - fun availablePackageFilters(currentFilters: List) = flow { - val packageNameFilters = appsService.getInstalledPackages() - .filter { currentFilters.none { packageFilter -> packageFilter.matches(it) } } - .map { it.packageName } - .splitIntoFilters() - emit(ViewState.Loaded(packageNameFilters)) - } - - private fun List.splitIntoFilters(): List { - val appSet = mutableSetOf() - forEach { packageName -> - var tempPackageName = packageName - appSet.add(tempPackageName) - while (tempPackageName.isNotEmpty()) { - val lastIndex = tempPackageName.lastIndexOf(".") - if (lastIndex > 0) { - tempPackageName = tempPackageName.substring(0, lastIndex) - appSet.add("$tempPackageName.*") - } else { - tempPackageName = "" - } - } - } - return appSet.toList().sortedWith(Collator.getInstance()) - } - - sealed class ViewState { - data object Loading : ViewState() - - data class Loaded(val data: List): ViewState() - data class Error(val message: String): ViewState() - } - -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackagePatternSheet.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackagePatternSheet.kt new file mode 100644 index 00000000..1d1bd396 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackagePatternSheet.kt @@ -0,0 +1,92 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo + +/** What a package name pattern being typed would match. */ +data class PatternPreview( + val pattern: String = "", + val isValid: Boolean = false, + val matchCount: Int = 0, + /** The first matching apps, by name. */ + val apps: List = emptyList() +) + +/** Enter a package name pattern and see which installed apps it matches before adding it. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PackagePatternSheet( + preview: PatternPreview, + onPatternChange: (String) -> Unit, + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = stringResource(R.string.setup_source_pattern), style = MaterialTheme.typography.titleLarge) + Text( + text = stringResource(R.string.add_filter_pattern_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val invalid = preview.pattern.isNotBlank() && !preview.isValid + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = preview.pattern, + onValueChange = onPatternChange, + singleLine = true, + isError = invalid, + label = { Text(stringResource(R.string.packagefilter)) }, + placeholder = { Text(stringResource(R.string.setup_source_pattern_summary)) }, + supportingText = when { + invalid -> { + { Text(stringResource(R.string.pattern_invalid)) } + } + preview.pattern.isNotBlank() -> { + { Text(pluralStringResource(R.plurals.pattern_match_count, preview.matchCount, preview.matchCount)) } + } + else -> null + }, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri) + ) + preview.apps.forEach { AppInfoItem(appInfo = it) } + val hidden = preview.matchCount - preview.apps.size + if (hidden > 0) { + Text( + text = pluralStringResource(R.plurals.widget_more_apps, hidden, hidden), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Button( + modifier = Modifier.align(Alignment.End), + enabled = preview.isValid, + onClick = { onAdd(preview.pattern.trim()) } + ) { + Text(stringResource(R.string.add)) + } + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt index 4bb95c2e..9e6fa1c7 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt @@ -63,10 +63,11 @@ private const val COLLAPSED_APP_COUNT = 5 @Composable fun WidgetProfileEditor( profileId: String, + isNew: Boolean = false, modifier: Modifier = Modifier, viewModel: WidgetProfileEditorViewModel = hiltViewModel( creationCallback = { factory: WidgetProfileEditorViewModel.Factory -> - factory.create(profileId) + factory.create(profileId, isNew) } ) ) { @@ -78,7 +79,7 @@ fun WidgetProfileEditor( // Changes save as they are made; a name still being typed is saved when the editor closes. DisposableEffect(viewModel) { - onDispose { viewModel.flushPendingChanges() } + onDispose { viewModel.onEditorClosed() } } Box(modifier = modifier.fillMaxSize()) { @@ -109,7 +110,10 @@ fun WidgetProfileEditor( WidgetProfileEditorDialogs.AddFilter -> AddFilterSheet( onDismiss = { currentDialog = WidgetProfileEditorDialogs.None }, onSignatureClick = { currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters) }, - onPatternClick = { currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters) } + onPatternClick = { + viewModel.onPatternChanged("") + currentDialog = WidgetProfileEditorDialogs.AddPackagePattern + } ) is WidgetProfileEditorDialogs.AddAppSignaturePackageFilter -> AddAppSignatureFilterSheet( currentFilters = dialog.currentPackageFilters, @@ -128,22 +132,20 @@ fun WidgetProfileEditor( currentDialog = WidgetProfileEditorDialogs.None } ) - is WidgetProfileEditorDialogs.AddPackageNamePackageFilter -> AddPackageNamePackageFilterDialog( - currentFilters = dialog.currentPackageFilters, - closeDialog = { - currentDialog = WidgetProfileEditorDialogs.None - }, - addFilter = { packageNameFilter -> - viewModel.addPackageFilter( - PackageFilter( - type = FilterType.PACKAGE_NAME, - filter = packageNameFilter, - profileId = viewState.widgetProfile?.id.orEmpty() + WidgetProfileEditorDialogs.AddPackagePattern -> { + val preview by viewModel.patternPreview.collectAsState() + PackagePatternSheet( + preview = preview, + onPatternChange = viewModel::onPatternChanged, + onAdd = { pattern -> + viewModel.addPackageFilter( + PackageFilter(type = FilterType.PACKAGE_NAME, filter = pattern, profileId = viewState.widgetProfile?.id.orEmpty()) ) - ) - currentDialog = WidgetProfileEditorDialogs.None - } - ) + currentDialog = WidgetProfileEditorDialogs.None + }, + onDismiss = { currentDialog = WidgetProfileEditorDialogs.None } + ) + } is WidgetProfileEditorDialogs.PackageFilterInfo -> PackageFilterInfoDialog( packageFilter = dialog.packageFilter, onDismiss = { @@ -156,9 +158,7 @@ fun WidgetProfileEditor( private sealed class WidgetProfileEditorDialogs { data object None : WidgetProfileEditorDialogs() data object AddFilter : WidgetProfileEditorDialogs() - data class AddPackageNamePackageFilter( - val currentPackageFilters: List - ) : WidgetProfileEditorDialogs() + data object AddPackagePattern : WidgetProfileEditorDialogs() data class AddAppSignaturePackageFilter( val currentPackageFilters: List @@ -199,7 +199,17 @@ internal fun WidgetProfileEditor( singleLine = true, value = viewState.widgetName ?: widgetProfile.name, onValueChange = onNameChange, - label = { Text(text = stringResource(id = R.string.name)) } + label = { Text(text = stringResource(id = R.string.name)) }, + // Every edit here changes these widgets, so say which they are. + supportingText = { + Text( + if (viewState.usedByWidgets.isEmpty()) { + stringResource(R.string.profile_not_used) + } else { + stringResource(R.string.profile_used_by, viewState.usedByWidgets.joinToString()) + } + ) + } ) Column { diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt index e7640692..f8e78130 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModel.kt @@ -13,6 +13,7 @@ import de.psdev.devdrawer.appwidget.AppInfo import de.psdev.devdrawer.appwidget.PackageHashInfo import de.psdev.devdrawer.appwidget.SortOrder import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.FilterType import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.profiles.IPackageFilterRepository import kotlinx.coroutines.Job @@ -20,14 +21,20 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @HiltViewModel(assistedFactory = WidgetProfileEditorViewModel.Factory::class) class WidgetProfileEditorViewModel @AssistedInject constructor( @Assisted private val profileId: String, + /** Just created from the Profiles tab; discarded again if left without changes. */ + @Assisted private val isNew: Boolean, private val database: DevDrawerDatabase, private val packageFilterRepository: IPackageFilterRepository, private val appsService: IAppsService @@ -35,7 +42,7 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(profileId: String): WidgetProfileEditorViewModel + fun create(profileId: String, isNew: Boolean): WidgetProfileEditorViewModel } /** The name as typed; saved once typing pauses or the editor closes. */ @@ -62,17 +69,39 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( val state = combine( database.widgetProfileDao().widgetProfileWithIdObservable(profileId), widgetNameState, - matches - ) { widgetProfile, name, (filters, matchingApps, filterAppCounts) -> + matches, + database.widgetDao().findAllFlow().map { widgets -> + widgets.filter { it.profileId == profileId }.map { it.name }.sortedWith(String.CASE_INSENSITIVE_ORDER) + } + ) { widgetProfile, name, (filters, matchingApps, filterAppCounts), usedByWidgets -> WidgetProfileEditorViewState( widgetProfile = widgetProfile, widgetName = name ?: widgetProfile?.name.orEmpty(), packageFilters = filters, matchingApps = matchingApps, - filterAppCounts = filterAppCounts + filterAppCounts = filterAppCounts, + usedByWidgets = usedByWidgets ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetProfileEditorViewState.Empty) + private val patternState = MutableStateFlow("") + + /** What the package name pattern being entered would match; recomputed as it is typed. */ + val patternPreview: StateFlow = combine(patternState, installedPackages) { pattern, packages -> + val filter = PackageFilter(type = FilterType.PACKAGE_NAME, filter = pattern.trim(), profileId = profileId) + val matched = if (filter.isValidPattern) packages.matching(listOf(filter)) else emptyList() + PatternPreview( + pattern = pattern, + isValid = filter.isValidPattern, + matchCount = matched.size, + apps = appsService.appInfos(matched).sortedWith(SortOrder.NAME.comparator()).take(PATTERN_PREVIEW_APP_COUNT) + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PatternPreview()) + + fun onPatternChanged(pattern: String) { + patternState.value = pattern + } + fun onNameChanged(name: String) { widgetNameState.value = name pendingNameSave?.cancel() @@ -82,13 +111,24 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( } } - /** Saves a name that is still waiting for typing to pause; called when the editor closes. */ - fun flushPendingChanges() { + /** + * Saves a name still waiting for typing to pause, and discards a new profile that was left untouched + * (no filters, no name typed) so backing out of "Create new profile" leaves nothing behind. + */ + fun onEditorClosed() { val name = widgetNameState.value if (pendingNameSave?.isActive == true && name != null) { pendingNameSave?.cancel() saveName(name) } + if (isNew && name.isNullOrBlank()) { + write { + val profile = database.widgetProfileDao().findById(profileId) ?: return@write + if (database.packageFilterDao().findAllByProfile(profileId).isEmpty()) { + database.widgetProfileDao().delete(profile) + } + } + } } fun addPackageFilter(packageFilter: PackageFilter) { @@ -112,9 +152,14 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( } } - /** Edits are written straight away and finish even if the editor closes meanwhile. */ + private val writeMutex = Mutex() + + /** + * Edits are written straight away, one at a time and in order (so closing the editor right after adding a + * filter sees that filter), and finish even if the editor closes meanwhile. + */ private fun write(block: suspend () -> Unit) { - viewModelScope.launch { withContext(NonCancellable) { block() } } + viewModelScope.launch { withContext(NonCancellable) { writeMutex.withLock { block() } } } } private data class FilterMatches( @@ -125,5 +170,6 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( private companion object { const val NAME_SAVE_DELAY_MS = 500L + const val PATTERN_PREVIEW_APP_COUNT = 5 } } diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt index 149ceb46..257338a4 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewState.kt @@ -13,7 +13,9 @@ data class WidgetProfileEditorViewState( /** Installed apps the edited filters match, by name. */ val matchingApps: List = emptyList(), /** How many installed apps each filter matches on its own, by filter id. */ - val filterAppCounts: Map = emptyMap() + val filterAppCounts: Map = emptyMap(), + /** Names of the widgets showing this profile, which every edit affects. */ + val usedByWidgets: List = emptyList() ) { companion object { val Empty = WidgetProfileEditorViewState() diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt index fdc267fc..951ca11f 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/list/WidgetProfilesListScreen.kt @@ -49,7 +49,8 @@ import kotlinx.coroutines.launch @Composable fun WidgetProfilesScreen( viewModel: WidgetProfilesViewModel = hiltViewModel(), - onEditProfile: (WidgetProfile) -> Unit + onEditProfile: (WidgetProfile) -> Unit, + onProfileCreated: (WidgetProfile) -> Unit = onEditProfile ) { var deleteDialogShown by remember { mutableStateOf(DeleteDialogState.Hidden) } val viewState by viewModel.viewState.collectAsState() @@ -78,9 +79,7 @@ fun WidgetProfilesScreen( } }, onCreateWidgetProfileClick = { - viewModel.createNewProfile { widgetProfile -> - onEditProfile(widgetProfile) - } + viewModel.createNewProfile(onProfileCreated) } ) SnackbarHost( @@ -110,7 +109,19 @@ fun WidgetProfileListScreen( onCreateWidgetProfileClick: () -> Unit = {} ) { when (viewState) { - is UiState.Loading, is UiState.Error -> LoadingView() + is UiState.Loading -> LoadingView() + is UiState.Error -> Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + Text(text = stringResource(R.string.profiles_load_failed), style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.error_message, viewState.cause.message ?: viewState.cause.javaClass.simpleName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } is UiState.Success -> { val profiles = viewState.data if (profiles.isEmpty()) { diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt index 064eef8d..fcdfdf8f 100644 --- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt @@ -118,6 +118,7 @@ fun SettingsScreen( PreferenceCategory(title = stringResource(id = R.string.settings_category_analytics)) SwitchPreference( text = stringResource(id = R.string.pref_feature_analytics_opted_in_title), + summary = stringResource(id = R.string.pref_feature_analytics_opted_in_summary), enabled = settings.analyticsOptIn ) { onAnalyticsOptInChanged(it) diff --git a/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt b/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt deleted file mode 100644 index 72ecc28b..00000000 --- a/app/src/main/java/de/psdev/devdrawer/ui/autocomplete/AutoCompleteTextView.kt +++ /dev/null @@ -1,158 +0,0 @@ -package de.psdev.devdrawer.ui.autocomplete - -import android.content.res.Configuration -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import de.psdev.devdrawer.R -import de.psdev.devdrawer.ui.theme.DevDrawerTheme -import kotlin.math.roundToInt - -@Composable -fun AutoCompleteTextView( - modifier: Modifier = Modifier, - options: List, - label: @Composable (() -> Unit)? = null, - onTextChanged: (String) -> Unit = {} -) { - val interactionSource = remember { MutableInteractionSource() } - val textFieldFocused by interactionSource.collectIsFocusedAsState() - var text by remember { mutableStateOf("") } - var textPosition by remember { - mutableStateOf(TextViewPositionData()) - } - SelectionContainer { - OutlinedTextField( - modifier = modifier - .fillMaxWidth() - .onGloballyPositioned { - textPosition = TextViewPositionData( - positionInWindow = it.positionInWindow(), - positionInParent = it.positionInParent(), - size = it.size - ) - }, - value = text, - trailingIcon = { - if (text.isNotBlank()) { - IconButton(onClick = { - text = "" - onTextChanged(text) - }) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = stringResource(id = R.string.clear) - ) - } - } - }, - interactionSource = interactionSource, - onValueChange = { - text = it - onTextChanged(text) - }, - label = label, - keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Ascii) - ) - } - if (textFieldFocused) { - val textFieldStart = textPosition.positionInParent.x.roundToInt() - val textFieldBottom = (textPosition.positionInParent.y + textPosition.size.height).roundToInt() - val popupOffset = IntOffset(textFieldStart, textFieldBottom) - val popupWidth = with(LocalDensity.current) { textPosition.size.width.toDp() } - val bottom = LocalView.current.height - val popupHeightMax = with(LocalDensity.current) { (bottom - textFieldBottom).toDp() } - Popup( - offset = popupOffset, - ) { - Surface( - modifier = Modifier - .requiredWidth(popupWidth) - .heightIn(max = popupHeightMax), - shadowElevation = 2.dp - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - ) { - items(options.filter { option -> option.contains(text) && option != text }) { packageName -> - Text( - modifier = Modifier - .fillMaxWidth() - .clickable { - text = packageName - onTextChanged(text) - } - .padding(16.dp), - text = packageName - ) - } - } - } - } - } -} - -data class TextViewPositionData( - val positionInWindow: Offset = Offset.Unspecified, - val positionInParent: Offset = Offset.Unspecified, - val size: IntSize = IntSize.Zero -) - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun Preview_AutoCompleteTextView() { - DevDrawerTheme { - Surface { - Column { - AutoCompleteTextView( - options = listOf( - "com.example.app1", - "com.example.app2", - "com.example.app3", - "com.example.app4", - ), - label = { Text(text = stringResource(id = R.string.packagefilter)) }, - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt index 61ee8633..acbe9668 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -86,12 +87,22 @@ fun WidgetListScreen( text = stringResource(id = R.string.no_widgets_created), color = MaterialTheme.colorScheme.onBackground ) + if (!state.isRequestPinAppWidgetSupported) { + // This launcher can't place widgets for the app, so say where to find them. + Text( + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + text = stringResource(R.string.add_widget_from_home_screen), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } if (state.isRequestPinAppWidgetSupported) { Spacer(modifier = Modifier.size(16.dp)) Button(onClick = onRequestPinWidgetClick) { Icon( painter = painterResource(id = R.drawable.ic_outline_add_box_24), - contentDescription = stringResource(id = R.string.add_widget) + contentDescription = null ) Text( modifier = Modifier.padding(start = 8.dp), diff --git a/app/src/main/res/drawable-mdpi/background_texture.png b/app/src/main/res/drawable-mdpi/background_texture.png deleted file mode 100644 index 4fb526db..00000000 Binary files a/app/src/main/res/drawable-mdpi/background_texture.png and /dev/null differ diff --git a/app/src/main/res/drawable-night/background_texture.png b/app/src/main/res/drawable-night/background_texture.png deleted file mode 100644 index 60ba775e..00000000 Binary files a/app/src/main/res/drawable-night/background_texture.png and /dev/null differ diff --git a/app/src/main/res/drawable/background_repeat.xml b/app/src/main/res/drawable/background_repeat.xml deleted file mode 100644 index ee66cdb9..00000000 --- a/app/src/main/res/drawable/background_repeat.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_choice.xml b/app/src/main/res/layout/activity_choice.xml deleted file mode 100644 index 23426d10..00000000 --- a/app/src/main/res/layout/activity_choice.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_choice_list_item.xml b/app/src/main/res/layout/activity_choice_list_item.xml deleted file mode 100644 index f60521a1..00000000 --- a/app/src/main/res/layout/activity_choice_list_item.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 671c5e9b..227bf33c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -6,7 +6,7 @@ Profile Widgets Keine Profile - Neu + Profil %1$d Name Profil bearbeiten Hinzufügen @@ -23,7 +23,6 @@ Play Store Github Paketfilter - Paketnamen-Filter eingeben Signatur aus App auswählen Abbrechen Keine Widgets erstellt @@ -44,8 +43,8 @@ Design Material-You-Farben Farben aus deinem Hintergrundbild verwenden - Analyse zulassen - Leeren + Nutzungsanalyse teilen + Welche Bildschirme du öffnest und Absturzberichte, über Firebase. Keine personenbezogenen Daten. Keine Apps verfügbar oder alle bereits als Filter hinzugefügt Mehr System-Apps einbeziehen @@ -168,4 +167,19 @@ Jede App, die mit demselben Schlüssel signiert ist, passt. Apps suchen Keine App passt zur Suche + Kein gültiges Muster + + %d App passt + %d Apps passen + + %1$s öffnen + Hauptbildschirm · %1$s + Dieser Bildschirm lässt sich nicht öffnen + Diese App ist nicht mehr installiert + System-Apps können nicht deinstalliert werden + Dein Gerät unterstützt das nicht + Version %1$s + Zurück + Profile konnten nicht geladen werden + Halte den Startbildschirm gedrückt, tippe auf „Widgets“ und ziehe DevDrawer darauf. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53dcc261..82b68974 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,7 +46,7 @@ Profiles Widgets No profiles - New + Profile %1$d Name Edit profile Add @@ -58,7 +58,6 @@ Delete Package name App icon - Enter package name filter Select signature from app Cancel No widgets created @@ -93,8 +92,8 @@ Material You colors Use colors generated from your wallpaper - Opt-in to analytics - Clear + Share usage analytics + Which screens you open and crash reports, via Firebase. No personal data. No apps available or all already added as filter More @@ -208,4 +207,21 @@ Every app signed with the same key will match. Search apps No apps match your search + Not a valid pattern + + %d app matches + %d apps match + + Open %1$s + Main screen · %1$s + Couldn\'t open this screen + This app is no longer installed + System apps can\'t be uninstalled + Your device can\'t do this + Version %1$s + Play Store + GitHub + Back + Couldn\'t load profiles + Long-press your home screen, tap Widgets and drag DevDrawer onto it. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index abff3ca2..25a32adb 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -28,14 +28,13 @@ @style/Theme.Settings.Preferences -