From c04b2a9bc438879f6df86aa5cf5c9aacb19c8fa0 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 15:50:55 +0200 Subject: [PATCH 01/15] Add widget header colour and sort order, share app matching - Widget gains header_color (curated palette: amber, neutral, dark, dynamic) and a nullable per-widget sort_order. Migration 3 to 4 maps black and dark-grey widgets to the dark header, the rest to amber. - matching() and SortOrder.comparator() extract the profile matching and sorting from the RemoteViews factory, so the widget and the app screens can share them. - room-testing plus the schema exports in debug assets let MigrationTestHelper run under Robolectric. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle | 6 + .../de/psdev/devdrawer/apps/AppMatching.kt | 18 ++ .../de/psdev/devdrawer/database/Converters.kt | 13 ++ .../devdrawer/database/DatabaseModule.kt | 1 + .../devdrawer/database/DevDrawerDatabase.kt | 2 +- .../de/psdev/devdrawer/database/Migrations.kt | 9 + .../de/psdev/devdrawer/database/Widget.kt | 9 +- .../devdrawer/database/WidgetHeaderColor.kt | 9 + .../psdev/devdrawer/apps/AppMatchingTest.kt | 85 ++++++++ .../database/MigrationFrom3To4Test.kt | 52 +++++ gradle/libs.versions.toml | 1 + .../4.json | 190 ++++++++++++++++++ 12 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/psdev/devdrawer/apps/AppMatching.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt create mode 100644 schemas/de.psdev.devdrawer.database.DevDrawerDatabase/4.json diff --git a/app/build.gradle b/app/build.gradle index d46273f1..7e2fc1b2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,6 +50,11 @@ android { includeAndroidResources = true } } + sourceSets { + // Room schema exports for MigrationTestHelper. Robolectric reads the app's merged assets, so they go into + // debug builds (a few KB of JSON); release builds don't include them. + debug.assets.srcDirs += files("$rootDir/schemas") + } final def keystorePropertiesFile = rootProject.file("release.properties") signingConfigs { debug { @@ -123,6 +128,7 @@ dependencies { testImplementation libs.robolectric testImplementation libs.mockk testImplementation libs.kotlinx.coroutines.test + testImplementation libs.androidx.room.testing // // Runtime dependencies diff --git a/app/src/main/java/de/psdev/devdrawer/apps/AppMatching.kt b/app/src/main/java/de/psdev/devdrawer/apps/AppMatching.kt new file mode 100644 index 00000000..cb3e604e --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/apps/AppMatching.kt @@ -0,0 +1,18 @@ +package de.psdev.devdrawer.apps + +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.database.PackageFilter + +/** The packages a profile shows: those matching at least one of its [filters], each once. */ +fun List.matching(filters: List): List = filter { packageInfo -> + filters.any { it.matches(packageInfo) } +}.distinctBy { it.packageName } + +fun SortOrder.comparator(): Comparator = when (this) { + SortOrder.FIRST_INSTALLED -> compareByDescending { it.firstInstallTime } + SortOrder.LAST_UPDATED -> compareByDescending { it.lastUpdateTime } + SortOrder.NAME -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.name } + SortOrder.PACKAGE_NAME -> compareBy { it.packageName } +} diff --git a/app/src/main/java/de/psdev/devdrawer/database/Converters.kt b/app/src/main/java/de/psdev/devdrawer/database/Converters.kt index 8f344557..66e93679 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Converters.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Converters.kt @@ -1,6 +1,7 @@ package de.psdev.devdrawer.database import androidx.room.TypeConverter +import de.psdev.devdrawer.appwidget.SortOrder import java.time.Instant class Converters { @@ -10,6 +11,18 @@ class Converters { @TypeConverter fun toFilterType(value: String?): FilterType? = value?.let { FilterType.valueOf(it) } + @TypeConverter + fun fromWidgetHeaderColor(value: WidgetHeaderColor?): String? = value?.name + + @TypeConverter + fun toWidgetHeaderColor(value: String?): WidgetHeaderColor? = value?.let { WidgetHeaderColor.valueOf(it) } + + @TypeConverter + fun fromSortOrder(value: SortOrder?): String? = value?.name + + @TypeConverter + fun toSortOrder(value: String?): SortOrder? = value?.let { SortOrder.valueOf(it) } + @TypeConverter fun fromOffsetDateTIme(value: Instant?): Long? = value?.toEpochMilli() diff --git a/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt b/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt index 4d06a85c..975711f7 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/DatabaseModule.kt @@ -21,6 +21,7 @@ class DatabaseModule { ).apply { addMigrations(MigrationFrom1To2(application)) addMigrations(MigrationFrom2To3) + addMigrations(MigrationFrom3To4) }.build() } \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt b/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt index 64c16385..a7824b5e 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/DevDrawerDatabase.kt @@ -16,7 +16,7 @@ abstract class DevDrawerDatabase: RoomDatabase() { companion object { const val NAME = "DevDrawer.db" - const val VERSION = 3 + const val VERSION = 4 } abstract fun widgetDao(): WidgetDao diff --git a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt index 4f8160b5..ae859de9 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt @@ -47,3 +47,12 @@ object MigrationFrom2To3 : Migration(2, 3) { db.execSQL("UPDATE `widget_profiles` SET `updatedAt` = ${System.currentTimeMillis()};") } } + +/** Adds the curated header colour and the per-widget sort order. Dark legacy colours keep a dark header. */ +object MigrationFrom3To4 : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `widgets` ADD COLUMN `header_color` TEXT NOT NULL DEFAULT 'AMBER'") + db.execSQL("ALTER TABLE `widgets` ADD COLUMN `sort_order` TEXT") + db.execSQL("UPDATE `widgets` SET `header_color` = 'DARK' WHERE `color` IN (${Color.BLACK}, ${Color.DKGRAY})") + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/database/Widget.kt b/app/src/main/java/de/psdev/devdrawer/database/Widget.kt index 1fc441e1..83782974 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Widget.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Widget.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey +import de.psdev.devdrawer.appwidget.SortOrder @Entity( tableName = "widgets", @@ -21,8 +22,14 @@ data class Widget( val id: Int, @ColumnInfo(name = "name", index = true) var name: String, + /** Legacy ARGB header colour from before [headerColor]; kept so the table needs no rebuild. */ @ColumnInfo(name = "color", typeAffinity = ColumnInfo.INTEGER) var color: Int, @ColumnInfo(name = "profile_id", index = true) - var profileId: String + var profileId: String, + @ColumnInfo(name = "header_color", defaultValue = "AMBER") + val headerColor: WidgetHeaderColor = WidgetHeaderColor.AMBER, + /** Sort order for this widget; null uses the default from Settings. */ + @ColumnInfo(name = "sort_order") + val sortOrder: SortOrder? = null ) \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt new file mode 100644 index 00000000..d706bb72 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt @@ -0,0 +1,9 @@ +package de.psdev.devdrawer.database + +/** + * Header colour of a home-screen widget, from a curated palette. Each entry has a light and a dark variant + * with readable text; [DYNAMIC] follows the wallpaper (Material You) on Android 12+. + */ +enum class WidgetHeaderColor { + AMBER, NEUTRAL, DARK, DYNAMIC +} diff --git a/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt b/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt new file mode 100644 index 00000000..71ba6e3e --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt @@ -0,0 +1,85 @@ +package de.psdev.devdrawer.apps + +import android.graphics.drawable.ColorDrawable +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class AppMatchingTest { + + private val devDrawer = PackageHashInfo("de.psdev.devdrawer", firstInstallTime = 1, lastUpdateTime = 30, signatureHashSha256 = "key-a") + private val other = PackageHashInfo("com.example.other", firstInstallTime = 2, lastUpdateTime = 10, signatureHashSha256 = "key-a") + private val client = PackageHashInfo("com.example.client", firstInstallTime = 3, lastUpdateTime = 20, signatureHashSha256 = "key-b") + private val packages = listOf(devDrawer, other, client) + + @Test + fun `given a signature filter, when matching, then every package with that signature matches`() { + // Given + val filters = listOf(PackageFilter(type = FilterType.SIGNATURE, filter = "key-a", profileId = "p")) + + // When + val result = packages.matching(filters) + + // Then + assertEquals(listOf(devDrawer, other), result) + } + + @Test + fun `given two filters, when matching, then packages matching either appear once`() { + // Given + val filters = listOf( + PackageFilter(type = FilterType.SIGNATURE, filter = "key-a", profileId = "p"), + PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "p") + ) + + // When + val result = packages.matching(filters) + + // Then + assertEquals(listOf(devDrawer, other, client), result) + } + + @Test + fun `given no filters, when matching, then nothing matches`() { + // Given / When + val result = packages.matching(emptyList()) + + // Then + assertEquals(emptyList(), result) + } + + @Test + fun `given each sort order, when sorting apps, then they are ordered accordingly`() { + // Given + val apps = listOf( + app("DevDrawer2", devDrawer), + app("Other", other), + app("Client", client) + ) + + // When / Then + assertEquals(listOf("DevDrawer2", "Client", "Other"), apps.sortedWith(SortOrder.LAST_UPDATED.comparator()).map { it.name }) + assertEquals(listOf("Client", "Other", "DevDrawer2"), apps.sortedWith(SortOrder.FIRST_INSTALLED.comparator()).map { it.name }) + assertEquals(listOf("Client", "DevDrawer2", "Other"), apps.sortedWith(SortOrder.NAME.comparator()).map { it.name }) + assertEquals(listOf("Client", "Other", "DevDrawer2"), apps.sortedWith(SortOrder.PACKAGE_NAME.comparator()).map { it.name }) + } + + private fun app(name: String, info: PackageHashInfo) = AppInfo( + name = name, + packageName = info.packageName, + appIcon = ColorDrawable(0), + firstInstallTime = info.firstInstallTime, + lastUpdateTime = info.lastUpdateTime, + signatureHashSha256 = info.signatureHashSha256 + ) +} diff --git a/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt b/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt new file mode 100644 index 00000000..eef1c7c5 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt @@ -0,0 +1,52 @@ +package de.psdev.devdrawer.database + +import android.graphics.Color +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class MigrationFrom3To4Test { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + DevDrawerDatabase::class.java + ) + + @Test + fun `given widgets with legacy colours, when migrating to 4, then dark colours become the dark header and others amber`() { + // Given + helper.createDatabase(TEST_DB, 3).use { db -> + db.execSQL("INSERT INTO `widget_profiles` (`id`, `name`, `updatedAt`) VALUES ('p1', 'Default', 0)") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (1, 'Black', ${Color.BLACK}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (2, 'Dark grey', ${Color.DKGRAY}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (3, 'Yellow', ${Color.YELLOW}, 'p1')") + } + + // When + val db = helper.runMigrationsAndValidate(TEST_DB, 4, true, MigrationFrom3To4) + + // Then + val headerColors = mutableMapOf() + db.query("SELECT `id`, `header_color`, `sort_order` FROM `widgets`").use { cursor -> + while (cursor.moveToNext()) { + headerColors[cursor.getInt(0)] = cursor.getString(1) + assertNull(cursor.getString(2)) + } + } + assertEquals(mapOf(1 to "DARK", 2 to "DARK", 3 to "AMBER"), headerColors) + } + + private companion object { + const val TEST_DB = "migration-test" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e89e1f86..279cf0da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ androidx-preference-ktx = { module = "androidx.preference:preference-ktx", versi androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } diff --git a/schemas/de.psdev.devdrawer.database.DevDrawerDatabase/4.json b/schemas/de.psdev.devdrawer.database.DevDrawerDatabase/4.json new file mode 100644 index 00000000..f2d13515 --- /dev/null +++ b/schemas/de.psdev.devdrawer.database.DevDrawerDatabase/4.json @@ -0,0 +1,190 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "c53e8876691a50ec0e29bbf91828a541", + "entities": [ + { + "tableName": "widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `profile_id` TEXT NOT NULL, `header_color` TEXT NOT NULL DEFAULT 'AMBER', `sort_order` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerColor", + "columnName": "header_color", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'AMBER'" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_widgets_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_widgets_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_widgets_profile_id", + "unique": false, + "columnNames": [ + "profile_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_widgets_profile_id` ON `${TABLE_NAME}` (`profile_id`)" + } + ], + "foreignKeys": [ + { + "table": "widget_profiles", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "profile_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "widget_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "filters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `filter` TEXT NOT NULL, `description` TEXT NOT NULL, `profile_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profile_id`) REFERENCES `widget_profiles`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileId", + "columnName": "profile_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_filters_profile_id", + "unique": false, + "columnNames": [ + "profile_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_filters_profile_id` ON `${TABLE_NAME}` (`profile_id`)" + } + ], + "foreignKeys": [ + { + "table": "widget_profiles", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "profile_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c53e8876691a50ec0e29bbf91828a541')" + ] + } +} \ No newline at end of file From a6326c6afbaa54b91b8438e9c6efa1dd2f6af661 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 15:57:44 +0200 Subject: [PATCH 02/15] Move the home-screen widget to Jetpack Glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDWidgetProvider keeps its name (placed widgets depend on it) and is now a GlanceAppWidgetReceiver; WidgetService, the RemoteViews factory and its layouts are gone. - The widget has system corner radii, a tonal header from the curated palette (or the wallpaper colours), "7 apps · updated 15:12", app name before package, and quieter uninstall/app-details actions. - A widget whose profile matches nothing explains it and offers "Choose apps" instead of showing a blank list. - WidgetContentLoader assembles a widget's apps from the shared matching, honouring a per-widget sort order over the Settings one. App icons are scaled to the 40 dp they are drawn at. - widget_info: Glance loading layout, picker description, target size, reconfigurable; drops the unused autoAdvanceViewId. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle | 4 + app/src/main/AndroidManifest.xml | 4 - .../java/de/psdev/devdrawer/MainActivity.kt | 5 +- .../de/psdev/devdrawer/apps/AppsService.kt | 15 +- .../de/psdev/devdrawer/apps/IAppsService.kt | 13 ++ .../devdrawer/appwidget/DDWidgetProvider.kt | 116 +--------- .../appwidget/WidgetAppsListViewFactory.kt | 180 ---------------- .../appwidget/WidgetContentLoader.kt | 32 +++ .../psdev/devdrawer/appwidget/WidgetModule.kt | 39 ++++ .../devdrawer/appwidget/WidgetService.kt | 27 --- .../appwidget/glance/DevDrawerGlanceWidget.kt | 67 ++++++ .../glance/DevDrawerWidgetContent.kt | 200 ++++++++++++++++++ .../appwidget/glance/RefreshAction.kt | 13 ++ .../appwidget/glance/WidgetUiState.kt | 20 ++ .../devdrawer/database/PackageFilterDao.kt | 3 + .../devdrawer/database/RepositoryModule.kt | 5 + .../profiles/IPackageFilterRepository.kt | 3 + .../profiles/PackageFilterRepository.kt | 7 +- .../devdrawer/settings/SortOrderSetting.kt | 11 + .../devdrawer/ui/theme/WidgetHeaderPalette.kt | 25 +++ .../devdrawer/widgets/IWidgetRepository.kt | 1 + .../devdrawer/widgets/WidgetRepository.kt | 2 + .../res/drawable/listview_button_selector.xml | 7 - app/src/main/res/layout/list_item.xml | 77 ------- app/src/main/res/layout/widget_layout.xml | 76 ------- app/src/main/res/values-de/strings.xml | 15 ++ app/src/main/res/values-night/colors.xml | 3 - app/src/main/res/values/colors.xml | 3 - app/src/main/res/values/strings.xml | 15 ++ app/src/main/res/xml/widget_info.xml | 9 +- .../appwidget/WidgetContentLoaderTest.kt | 70 ++++++ .../glance/DevDrawerWidgetContentTest.kt | 67 ++++++ .../psdev/devdrawer/fakes/FakeAppsService.kt | 23 ++ .../fakes/FakePackageFilterRepository.kt | 32 +++ .../devdrawer/fakes/FakeWidgetRepository.kt | 2 + gradle/libs.versions.toml | 4 + 36 files changed, 702 insertions(+), 493 deletions(-) create mode 100644 app/src/main/java/de/psdev/devdrawer/apps/IAppsService.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/WidgetContentLoader.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/WidgetService.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerGlanceWidget.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContent.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/glance/WidgetUiState.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/settings/SortOrderSetting.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt delete mode 100644 app/src/main/res/drawable/listview_button_selector.xml delete mode 100644 app/src/main/res/layout/list_item.xml delete mode 100644 app/src/main/res/layout/widget_layout.xml create mode 100644 app/src/test/java/de/psdev/devdrawer/appwidget/WidgetContentLoaderTest.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/fakes/FakePackageFilterRepository.kt diff --git a/app/build.gradle b/app/build.gradle index 7e2fc1b2..b1043462 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -179,6 +179,10 @@ dependencies { debugImplementation libs.androidx.ui.test.manifest // Optional - Add full set of material icons implementation libs.androidx.material.icons.extended + // Home-screen widget + implementation libs.androidx.glance.appwidget + implementation libs.androidx.glance.material3 + testImplementation libs.androidx.glance.appwidget.testing // Optional - Integration with activities implementation libs.androidx.activity.compose // Optional - Integration with ViewModels diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baf6a5ce..b6f19dd5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -81,10 +81,6 @@ android:name=".receivers.PinWidgetSuccessReceiver" android:exported="false" /> - - (null) diff --git a/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt b/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt index 21c4dab7..274912d6 100644 --- a/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt +++ b/app/src/main/java/de/psdev/devdrawer/apps/AppsService.kt @@ -21,7 +21,7 @@ import javax.inject.Singleton @Singleton class AppsService @Inject constructor( private val application: Application -) { +) : IAppsService { private val packageManager by lazy { application.packageManager } @@ -54,6 +54,19 @@ class AppsService @Inject constructor( } } + override suspend fun installedPackages(includeSystemApps: Boolean): List = + if (includeSystemApps) { + withContext(Dispatchers.IO) { + packageManager.getInstalledPackages(getFlags()).map { it.toPackageHashInfo() } + } + } else { + getInstalledPackages(systemApps = false) + } + + override suspend fun appInfos(packages: List): List = withContext(Dispatchers.IO) { + packages.mapNotNull { it.toAppInfo(application) } + } + @Suppress("DEPRECATION") private fun getFlags() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { GET_SIGNING_CERTIFICATES diff --git a/app/src/main/java/de/psdev/devdrawer/apps/IAppsService.kt b/app/src/main/java/de/psdev/devdrawer/apps/IAppsService.kt new file mode 100644 index 00000000..f5b693b8 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/apps/IAppsService.kt @@ -0,0 +1,13 @@ +package de.psdev.devdrawer.apps + +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.PackageHashInfo + +interface IAppsService { + + /** Installed packages with their signature hashes, optionally without system apps. */ + suspend fun installedPackages(includeSystemApps: Boolean = true): List + + /** Labels and icons for [packages]; packages that can no longer be read are left out. */ + suspend fun appInfos(packages: List): List +} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt index 1335d456..72d2ebb1 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/DDWidgetProvider.kt @@ -1,63 +1,30 @@ package de.psdev.devdrawer.appwidget -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider import android.content.Context -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.widget.RemoteViews -import androidx.core.net.toUri +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.MainActivity -import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.glance.DevDrawerGlanceWidget import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.receivers.UpdateReceiver -import de.psdev.devdrawer.utils.textColorForBackground import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import mu.KLogging -import java.text.DateFormat -import java.util.Date import javax.inject.Inject /** * NOTE: Never rename this as it will break existing widgets. */ @AndroidEntryPoint -class DDWidgetProvider: AppWidgetProvider() { +class DDWidgetProvider : GlanceAppWidgetReceiver() { @Inject lateinit var devDrawerDatabase: DevDrawerDatabase - companion object: KLogging() + companion object : KLogging() - // ========================================================================================================================== - // AppWidgetProvider - // ========================================================================================================================== - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - val pendingResult = goAsync() - CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { - try { - for (appWidgetId in appWidgetIds) { - val widget = devDrawerDatabase.widgetDao().findById(appWidgetId) - if (widget != null) { - logger.info { "Update Widget $appWidgetId" } - updateWidget(context, widget, appWidgetManager) - } else { - logger.warn { "Widget $appWidgetId does not exist" } - } - } - } finally { - pendingResult.finish() - } - } - } + override val glanceAppWidget: GlanceAppWidget = DevDrawerGlanceWidget() override fun onDeleted(context: Context, appWidgetIds: IntArray) { super.onDeleted(context, appWidgetIds) @@ -71,75 +38,4 @@ class DDWidgetProvider: AppWidgetProvider() { } } } - - @Suppress("DEPRECATION") // notifyAppWidgetViewDataChanged is deprecated along with the RemoteViewsService pattern - private fun updateWidget(context: Context, widget: Widget, appWidgetManager: AppWidgetManager) { - logger.trace { "updateWidget(widget=$widget)" } - try { - val view = createRemoteViews(context, widget) - appWidgetManager.updateAppWidget(widget.id, view) - appWidgetManager.notifyAppWidgetViewDataChanged(intArrayOf(widget.id), R.id.listView) - } catch (e: Exception) { - logger.warn(e) { "Error updating widget: ${widget.id}: ${e.message}" } - } - } - - @Suppress("DEPRECATION") // setRemoteAdapter(Int, Intent) requires RemoteCollectionItems to replace — large refactor - private fun createRemoteViews(context: Context, widget: Widget): RemoteViews { - // Setup the widget, and data source / adapter - val widgetView = RemoteViews(context.packageName, R.layout.widget_layout) - val widgetColor = widget.color - val contrastColor = widgetColor.textColorForBackground() - - // Set background color for widget - widgetView.setInt(R.id.container_actions, "setBackgroundColor", widgetColor) - - widgetView.setTextViewText(R.id.txt_title, widget.name) - widgetView.setTextColor(R.id.txt_title, contrastColor) - widgetView.setTextViewText(R.id.txt_last_updated, DateFormat.getTimeInstance().format(Date())) - widgetView.setTextColor(R.id.txt_last_updated, contrastColor) - - val reloadPendingIntent = PendingIntent.getBroadcast( - context, - 0, - Intent(context, UpdateReceiver::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - widgetView.setOnClickPendingIntent(R.id.btn_reload, reloadPendingIntent) - - val configActivityIntent = Intent(context, MainActivity::class.java).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id) - putExtra("from_widget", true) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) - } - val configActivityPendingIntent = PendingIntent.getActivity( - context, - 0, - configActivityIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - widgetView.setOnClickPendingIntent(R.id.btn_settings, configActivityPendingIntent) - - // Apps list - val appListServiceIntent = Intent(context, WidgetService::class.java).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget.id) - putExtra("viewId", R.id.listView) - data = toUri(Intent.URI_INTENT_SCHEME).toUri() - } - widgetView.setRemoteAdapter(R.id.listView, appListServiceIntent) - - val clickIntent = Intent(context, ClickHandlingActivity::class.java).apply { - addFlags(FLAG_ACTIVITY_NEW_TASK) - } - val clickPI = PendingIntent.getActivity( - context, - 0, - clickIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - widgetView.setPendingIntentTemplate(R.id.listView, clickPI) - return widgetView - } - } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt deleted file mode 100644 index f355fa1c..00000000 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetAppsListViewFactory.kt +++ /dev/null @@ -1,180 +0,0 @@ -package de.psdev.devdrawer.appwidget - -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.widget.RemoteViews -import android.widget.RemoteViewsService -import de.psdev.devdrawer.R -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.settings.PreferenceKeys -import de.psdev.devdrawer.utils.Constants -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import mu.KLogging - -class WidgetAppsListViewFactory( - private val context: Context, - private val devDrawerDatabase: DevDrawerDatabase, - private val sharedPreferences: SharedPreferences, - intent: Intent -) : RemoteViewsService.RemoteViewsFactory { - - companion object : KLogging() - - private val appWidgetId: Int by lazy { - intent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID - ) - } - - private val packageManager: PackageManager by lazy { context.packageManager } - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - @Volatile - private var cachedApps: List = emptyList() - - private val apps: MutableList = mutableListOf() - - // ========================================================================================================================== - // RemoteViewsService.RemoteViewsFactory - // ========================================================================================================================== - - override fun onCreate() { - logger.warn { "onCreate" } - scope.launch { cachedApps = loadApps() } - } - - override fun onDataSetChanged() { - logger.warn { "onDataSetChanged" } - cachedApps = runBlocking(Dispatchers.IO) { loadApps() } - apps.clear() - apps.addAll(cachedApps) - } - - override fun onDestroy() { - logger.warn { "onDestroy" } - scope.cancel() - } - - override fun getCount(): Int = apps.size - - override fun getViewAt(position: Int): RemoteViews? { - logger.debug { "getViewAt[position=$position]" } - val (appName, packageName, appIcon) = apps[position] - - // Setup the list item and intents for on click - val row = RemoteViews(context.packageName, R.layout.list_item) - - try { - row.setTextViewText(R.id.packageNameTextView, packageName) - row.setTextViewText(R.id.appNameTextView, appName) - row.setImageViewBitmap(R.id.imageView, convertFromDrawable(appIcon)) - - val appDetailsClickIntent = Intent().apply { - putExtra(ClickHandlingActivity.EXTRA_LAUNCH_TYPE, Constants.LAUNCH_APP_DETAILS) - putExtra(ClickHandlingActivity.EXTRA_PACKAGE_NAME, packageName) - } - row.setOnClickFillInIntent(R.id.appDetailsImageButton, appDetailsClickIntent) - - val uninstallClickIntent = Intent().apply { - putExtra(ClickHandlingActivity.EXTRA_LAUNCH_TYPE, Constants.LAUNCH_UNINSTALL) - putExtra(ClickHandlingActivity.EXTRA_PACKAGE_NAME, packageName) - } - row.setOnClickFillInIntent(R.id.uninstallImageButton, uninstallClickIntent) - - val rowClickIntent = Intent().apply { - putExtra(ClickHandlingActivity.EXTRA_LAUNCH_TYPE, Constants.LAUNCH_APP) - putExtra(ClickHandlingActivity.EXTRA_PACKAGE_NAME, packageName) - } - row.setOnClickFillInIntent(R.id.container_row, rowClickIntent) - - return row - } catch (e: IndexOutOfBoundsException) { - logger.warn(e) { "Error: ${e.message}" } - return null - } - - } - - override fun getLoadingView(): RemoteViews? = null - - override fun getViewTypeCount(): Int = 1 - - override fun getItemId(position: Int): Long = apps[position].packageName.hashCode().toLong() - - override fun hasStableIds(): Boolean = true - - // ========================================================================================================================== - // Private API - // ========================================================================================================================== - - /** - * Method to get all apps from the app database and add to the dataset - */ - @Suppress("DEPRECATION") - private suspend fun loadApps(): List { - val widget = - devDrawerDatabase.widgetDao().findById(appWidgetId) ?: return emptyList() - - val packageFilters = devDrawerDatabase.packageFilterDao() - .findAllByProfile(widget.profileId) - - val installedPackages = - packageManager.getInstalledPackages(PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES) - .map { it.toPackageHashInfo() } - return installedPackages.asSequence() - .filter { packageInfo -> - packageFilters.any { filter -> filter.matches(packageInfo) } - } - .mapNotNull { it.toAppInfo(context) } - .sortedWith(appComparator) - .distinct() - .toList() - } - - private val appComparator: Comparator - get() { - val defaultSortOrder = context.getString(R.string.pref_sort_order_default) - return when (SortOrder.valueOf( - sharedPreferences.getString(PreferenceKeys.SORT_ORDER, defaultSortOrder) ?: defaultSortOrder - )) { - SortOrder.FIRST_INSTALLED -> compareByDescending { it.firstInstallTime } - SortOrder.LAST_UPDATED -> compareByDescending { it.lastUpdateTime } - SortOrder.NAME -> compareBy { it.name } - SortOrder.PACKAGE_NAME -> compareBy { it.packageName } - } - } - - /** - * Method to return a bitmap from drawable - */ - private fun convertFromDrawable(drawable: Drawable): Bitmap { - return if (drawable is BitmapDrawable) { - drawable.bitmap - } else { - getBitmapFromDrawable(drawable) - } - } - - private fun getBitmapFromDrawable(drawable: Drawable): Bitmap { - val bmp = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - return bmp - } - -} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetContentLoader.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetContentLoader.kt new file mode 100644 index 00000000..f8b0dc93 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetContentLoader.kt @@ -0,0 +1,32 @@ +package de.psdev.devdrawer.appwidget + +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.apps.comparator +import de.psdev.devdrawer.apps.matching +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import de.psdev.devdrawer.widgets.IWidgetRepository + +/** What a home-screen widget shows: its settings and the apps its profile matches, in the widget's sort order. */ +data class WidgetContent( + val widget: Widget, + val apps: List +) + +class WidgetContentLoader( + private val widgetRepository: IWidgetRepository, + private val packageFilterRepository: IPackageFilterRepository, + private val appsService: IAppsService, + private val defaultSortOrder: suspend () -> SortOrder +) { + + /** Null when no widget with [widgetId] is saved yet (just placed, setup not finished). */ + suspend fun load(widgetId: Int): WidgetContent? { + val widget = widgetRepository.findById(widgetId) ?: return null + val filters = packageFilterRepository.findAllByProfile(widget.profileId) + val packages = appsService.installedPackages(includeSystemApps = true).matching(filters) + val sortOrder = widget.sortOrder ?: defaultSortOrder() + val apps = appsService.appInfos(packages).sortedWith(sortOrder.comparator()) + return WidgetContent(widget = widget, apps = apps) + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt new file mode 100644 index 00000000..66343a21 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt @@ -0,0 +1,39 @@ +package de.psdev.devdrawer.appwidget + +import android.app.Application +import android.content.SharedPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import de.psdev.devdrawer.settings.defaultSortOrder +import de.psdev.devdrawer.widgets.IWidgetRepository + +@Module +@InstallIn(SingletonComponent::class) +class WidgetModule { + + @Provides + fun widgetContentLoader( + application: Application, + widgetRepository: IWidgetRepository, + packageFilterRepository: IPackageFilterRepository, + appsService: IAppsService, + sharedPreferences: SharedPreferences + ): WidgetContentLoader = WidgetContentLoader( + widgetRepository = widgetRepository, + packageFilterRepository = packageFilterRepository, + appsService = appsService, + defaultSortOrder = { sharedPreferences.defaultSortOrder(application) } + ) +} + +/** Hilt access for the Glance widget, which the framework creates without injection. */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WidgetEntryPoint { + fun widgetContentLoader(): WidgetContentLoader +} diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetService.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetService.kt deleted file mode 100644 index 6db29ac8..00000000 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package de.psdev.devdrawer.appwidget - -import android.content.Intent -import android.content.SharedPreferences -import android.widget.RemoteViewsService -import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.database.DevDrawerDatabase -import javax.inject.Inject - -@AndroidEntryPoint -class WidgetService : RemoteViewsService() { - - @Inject - lateinit var devDrawerDatabase: DevDrawerDatabase - - @Inject - lateinit var sharedPreferences: SharedPreferences - - override fun onGetViewFactory( - intent: Intent - ): RemoteViewsFactory = WidgetAppsListViewFactory( - context = applicationContext, - devDrawerDatabase = devDrawerDatabase, - sharedPreferences = sharedPreferences, - intent = intent - ) -} \ No newline at end of file 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 new file mode 100644 index 00000000..99605b75 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerGlanceWidget.kt @@ -0,0 +1,67 @@ +package de.psdev.devdrawer.appwidget.glance + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.text.format.DateFormat +import androidx.core.graphics.drawable.toBitmap +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.provideContent +import androidx.glance.material3.ColorProviders +import dagger.hilt.android.EntryPointAccessors +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.WidgetContent +import de.psdev.devdrawer.appwidget.WidgetEntryPoint +import de.psdev.devdrawer.database.WidgetHeaderColor +import de.psdev.devdrawer.ui.theme.DarkColorScheme +import de.psdev.devdrawer.ui.theme.LightColorScheme +import java.util.Date + +/** The home-screen widget: a header with the widget's name, then the apps its profile matches. */ +class DevDrawerGlanceWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val loader = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java).widgetContentLoader() + val state = loader.load(appWidgetId).toUiState(context, appWidgetId) + val useDynamicColors = state.headerColor == WidgetHeaderColor.DYNAMIC && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + provideContent { + if (useDynamicColors) { + GlanceTheme { DevDrawerWidgetContent(state) } + } else { + GlanceTheme(colors = AppColors) { DevDrawerWidgetContent(state) } + } + } + } + + private companion object { + val AppColors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme) + + const val ICON_SIZE_DP = 40 + } + + /** A widget whose setup isn't finished yet shows its empty state under the app's name. */ + private fun WidgetContent?.toUiState(context: Context, appWidgetId: Int): WidgetUiState = WidgetUiState( + appWidgetId = appWidgetId, + title = this?.widget?.name ?: context.getString(R.string.app_name), + headerColor = this?.widget?.headerColor ?: WidgetHeaderColor.AMBER, + updatedAt = DateFormat.getTimeFormat(context).format(Date()), + apps = this?.apps.orEmpty().map { it.toItem(context) } + ) + + private fun AppInfo.toItem(context: Context): WidgetAppItem = WidgetAppItem( + name = name, + packageName = packageName, + icon = scaledIcon(context) + ) + + /** Every icon travels in the widget's RemoteViews, so it is scaled to the 40 dp it is drawn at. */ + private fun AppInfo.scaledIcon(context: Context): Bitmap? { + val size = (ICON_SIZE_DP * context.resources.displayMetrics.density).toInt() + return runCatching { appIcon.toBitmap(size, size) }.getOrNull() + } +} 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 new file mode 100644 index 00000000..d8d73a23 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContent.kt @@ -0,0 +1,200 @@ +package de.psdev.devdrawer.appwidget.glance + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.Button +import androidx.glance.ButtonDefaults +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.psdev.devdrawer.MainActivity +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.ClickHandlingActivity +import de.psdev.devdrawer.ui.theme.dark +import de.psdev.devdrawer.ui.theme.light +import de.psdev.devdrawer.utils.Constants + +private val PackageNameKey = ActionParameters.Key(ClickHandlingActivity.EXTRA_PACKAGE_NAME) +private val LaunchTypeKey = ActionParameters.Key(ClickHandlingActivity.EXTRA_LAUNCH_TYPE) + +@Composable +fun DevDrawerWidgetContent(state: WidgetUiState) { + val context = LocalContext.current + val background = GlanceModifier + .fillMaxSize() + .appWidgetBackground() + .background(GlanceTheme.colors.widgetBackground) + val rounded = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + background.cornerRadius(android.R.dimen.system_app_widget_background_radius) + } else { + background.cornerRadius(16.dp) + } + Column(modifier = rounded) { + Header(state) + if (state.apps.isEmpty()) { + EmptyState(context, state.appWidgetId) + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { + items(state.apps, itemId = { it.packageName.hashCode().toLong() }) { app -> + AppRow(context, app) + } + } + } + } +} + +@Composable +private fun Header(state: WidgetUiState) { + val context = LocalContext.current + val light = state.headerColor.light + val dark = state.headerColor.dark + val container = if (light != null && dark != null) ColorProvider(light.container, dark.container) else GlanceTheme.colors.primaryContainer + val content = if (light != null && dark != null) ColorProvider(light.content, dark.content) else GlanceTheme.colors.onPrimaryContainer + val count = if (state.apps.isEmpty()) { + context.getString(R.string.widget_no_apps) + } else { + context.resources.getQuantityString(R.plurals.widget_app_count, state.apps.size, state.apps.size) + } + Row( + modifier = GlanceModifier.fillMaxWidth().background(container).padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = GlanceModifier.defaultWeight()) { + Text( + text = state.title, + 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) + ) + } + Image( + provider = ImageProvider(R.drawable.ic_baseline_refresh_24), + contentDescription = context.getString(R.string.reload), + colorFilter = ColorFilter.tint(content), + modifier = GlanceModifier.size(48.dp).padding(12.dp).clickable(actionRunCallback()) + ) + Image( + provider = ImageProvider(R.drawable.ic_baseline_settings_24), + contentDescription = context.getString(R.string.widget_settings), + colorFilter = ColorFilter.tint(content), + modifier = GlanceModifier.size(48.dp).padding(12.dp) + .clickable(actionStartActivity(mainActivityIntent(context, state.appWidgetId, openSetup = false))) + ) + } +} + +@Composable +private fun EmptyState(context: Context, appWidgetId: Int) { + Column( + modifier = GlanceModifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = context.getString(R.string.widget_empty_title), + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 16.sp, fontWeight = FontWeight.Medium) + ) + Spacer(GlanceModifier.height(4.dp)) + Text( + text = context.getString(R.string.widget_empty_text), + 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 + ) + ) + } +} + +@Composable +private fun AppRow(context: Context, app: WidgetAppItem) { + Row( + modifier = GlanceModifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .clickable(clickHandlingAction(app.packageName, Constants.LAUNCH_APP)), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = app.icon?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.ic_baseline_widgets_24), + contentDescription = null, + modifier = GlanceModifier.size(40.dp) + ) + Column(modifier = GlanceModifier.defaultWeight().padding(start = 12.dp)) { + Text( + text = app.name, + 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) + ) + } + 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), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + modifier = GlanceModifier.size(48.dp).padding(12.dp) + .clickable(clickHandlingAction(app.packageName, Constants.LAUNCH_APP_DETAILS)) + ) + } +} + +private fun clickHandlingAction(packageName: String, launchType: Int) = actionStartActivity( + actionParametersOf(PackageNameKey to packageName, LaunchTypeKey to launchType) +) + +private fun mainActivityIntent(context: Context, appWidgetId: Int, openSetup: Boolean): Intent = + Intent(context, MainActivity::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + putExtra(MainActivity.EXTRA_OPEN_SETUP, openSetup) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt new file mode 100644 index 00000000..dd72d544 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt @@ -0,0 +1,13 @@ +package de.psdev.devdrawer.appwidget.glance + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback + +/** The header's reload button: redraws this widget with the currently installed apps. */ +class RefreshAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + DevDrawerGlanceWidget().update(context, glanceId) + } +} 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 new file mode 100644 index 00000000..6c7916c4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/WidgetUiState.kt @@ -0,0 +1,20 @@ +package de.psdev.devdrawer.appwidget.glance + +import android.graphics.Bitmap +import de.psdev.devdrawer.database.WidgetHeaderColor + +/** Everything the widget draws, already resolved (formatted time, scaled icons). */ +data class WidgetUiState( + val appWidgetId: Int, + val title: String, + val headerColor: WidgetHeaderColor, + val updatedAt: String, + val apps: List +) + +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? +) diff --git a/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt b/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt index c88bbde9..1aaae010 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/PackageFilterDao.kt @@ -16,6 +16,9 @@ abstract class PackageFilterDao : BaseDao() { @Query("SELECT * FROM filters WHERE profile_id = :profileId") abstract fun findAllByProfileFlow(profileId: String): Flow> + @Query("SELECT * FROM filters") + abstract fun findAllFlow(): Flow> + @Query("DELETE FROM filters WHERE id = :id") abstract suspend fun deleteById(id: String) diff --git a/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt b/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt index 1762193b..f73fd9c5 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt @@ -4,6 +4,8 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import de.psdev.devdrawer.apps.AppsService +import de.psdev.devdrawer.apps.IAppsService import de.psdev.devdrawer.profiles.IPackageFilterRepository import de.psdev.devdrawer.profiles.IWidgetProfileRepository import de.psdev.devdrawer.profiles.PackageFilterRepository @@ -21,6 +23,9 @@ abstract class RepositoryModule { @Binds abstract fun bindWidgetProfileRepository(impl: WidgetProfileRepository): IWidgetProfileRepository + @Binds + abstract fun bindAppsService(impl: AppsService): IAppsService + @Binds abstract fun bindPackageFilterRepository(impl: PackageFilterRepository): IPackageFilterRepository diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt index 849ad033..5ac9fb04 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/IPackageFilterRepository.kt @@ -2,10 +2,13 @@ package de.psdev.devdrawer.profiles import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile +import kotlinx.coroutines.flow.Flow interface IPackageFilterRepository { suspend fun getById(packageFilterId: String): PackageFilter? suspend fun delete(packageFilter: PackageFilter) suspend fun save(packageFilter: PackageFilter) suspend fun saveProfile(widgetProfile: WidgetProfile, filters: List) + suspend fun findAllByProfile(profileId: String): List + fun allFiltersFlow(): Flow> } diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt index 158aba59..893348e5 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/PackageFilterRepository.kt @@ -6,6 +6,7 @@ import de.psdev.devdrawer.database.DevDrawerDatabase import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.receivers.UpdateReceiver +import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -37,4 +38,8 @@ class PackageFilterRepository @Inject constructor( UpdateReceiver.send(application) } -} \ No newline at end of file + override suspend fun findAllByProfile(profileId: String): List = + devDrawerDatabase.packageFilterDao().findAllByProfile(profileId) + + override fun allFiltersFlow(): Flow> = devDrawerDatabase.packageFilterDao().findAllFlow() +} diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SortOrderSetting.kt b/app/src/main/java/de/psdev/devdrawer/settings/SortOrderSetting.kt new file mode 100644 index 00000000..e662a0df --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SortOrderSetting.kt @@ -0,0 +1,11 @@ +package de.psdev.devdrawer.settings + +import android.content.Context +import android.content.SharedPreferences +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.SortOrder + +/** The sort order Settings sets for widgets that don't have their own. */ +fun SharedPreferences.defaultSortOrder(context: Context): SortOrder = SortOrder.valueOf( + getString(PreferenceKeys.SORT_ORDER, null) ?: context.getString(R.string.pref_sort_order_default) +) diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt new file mode 100644 index 00000000..3c701e5e --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt @@ -0,0 +1,25 @@ +package de.psdev.devdrawer.ui.theme + +import androidx.compose.ui.graphics.Color +import de.psdev.devdrawer.database.WidgetHeaderColor + +/** A header colour pair: the container and the text and icons on it, each readable at 4.5:1 or better. */ +data class HeaderColorPair(val container: Color, val content: Color) + +/** Light-theme colours of a palette entry; null for [WidgetHeaderColor.DYNAMIC], which comes from the wallpaper. */ +val WidgetHeaderColor.light: HeaderColorPair? + get() = when (this) { + WidgetHeaderColor.AMBER -> HeaderColorPair(Amber200, Color(0xFF261900)) + WidgetHeaderColor.NEUTRAL -> HeaderColorPair(Color(0xFFE9E1D9), Color(0xFF1E1B16)) + WidgetHeaderColor.DARK -> HeaderColorPair(Color(0xFF1E1B16), Color(0xFFFFFFFF)) + WidgetHeaderColor.DYNAMIC -> null + } + +/** Dark-theme colours of a palette entry; null for [WidgetHeaderColor.DYNAMIC]. */ +val WidgetHeaderColor.dark: HeaderColorPair? + get() = when (this) { + WidgetHeaderColor.AMBER -> HeaderColorPair(Color(0xFF5E4200), Amber200) + WidgetHeaderColor.NEUTRAL -> HeaderColorPair(Color(0xFF38342E), Color(0xFFE9E1D9)) + WidgetHeaderColor.DARK -> HeaderColorPair(Color(0xFF000000), Color(0xFFE9E1D9)) + WidgetHeaderColor.DYNAMIC -> null + } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt index bc744153..eded7111 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt @@ -7,5 +7,6 @@ interface IWidgetRepository { fun widgetFlow(widgetId: Int): Flow suspend fun update(widget: Widget) suspend fun delete(widget: Widget) + suspend fun findById(widgetId: Int): Widget? suspend fun findWidgetsForProfile(profileId: String): List } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt index 04b77969..fa6211d7 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt @@ -25,6 +25,8 @@ class WidgetRepository @Inject constructor( UpdateReceiver.send(application) } + override suspend fun findById(widgetId: Int): Widget? = devDrawerDatabase.widgetDao().findById(widgetId) + override suspend fun findWidgetsForProfile(profileId: String): List = devDrawerDatabase.widgetDao().findAllByProfileId(profileId) diff --git a/app/src/main/res/drawable/listview_button_selector.xml b/app/src/main/res/drawable/listview_button_selector.xml deleted file mode 100644 index 4a9e50b9..00000000 --- a/app/src/main/res/drawable/listview_button_selector.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/list_item.xml b/app/src/main/res/layout/list_item.xml deleted file mode 100644 index edff5f9a..00000000 --- a/app/src/main/res/layout/list_item.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/widget_layout.xml b/app/src/main/res/layout/widget_layout.xml deleted file mode 100644 index 34151742..00000000 --- a/app/src/main/res/layout/widget_layout.xml +++ /dev/null @@ -1,76 +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 6c88adec..5adb4689 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -88,4 +88,19 @@ Fehler: %1$s Das Profil %1$s wird verwendet von: \n%2$s Diese App hat keine Activities + + + Deine eigenen Apps, nur einen Tipp entfernt + Keine Apps + %1$s · aktualisiert %2$s + Keine App passt zu diesem Widget + Wähle, welche deiner Apps es anzeigen soll. + Apps auswählen + Widget-Einstellungen + %1$s deinstallieren + App-Details für %1$s + + %d App + %d Apps + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 3567b99f..882b80ff 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -16,8 +16,5 @@ #FFFFFF - #FFF - #CBFFFFFF - #4DFFC107 #FFF diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b89360bd..7b3fcf41 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -16,9 +16,6 @@ #FFFFFF - #000 - #222 - #4DFFC107 #000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70c8dd75..3caf2ac0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,4 +128,19 @@ Error: %1$s The profile %1$s is used by: \n%2$s This app has no activities + + + Your own apps, one tap away + No apps + %1$s · updated %2$s + No apps match this widget + Choose which of your apps it should list. + Choose apps + Widget settings + Uninstall %1$s + App details for %1$s + + %d app + %d apps + diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml index c2c6837e..78f0c62a 100644 --- a/app/src/main/res/xml/widget_info.xml +++ b/app/src/main/res/xml/widget_info.xml @@ -1,13 +1,16 @@ \ No newline at end of file + android:widgetCategory="keyguard|home_screen" + android:widgetFeatures="reconfigurable" /> diff --git a/app/src/test/java/de/psdev/devdrawer/appwidget/WidgetContentLoaderTest.kt b/app/src/test/java/de/psdev/devdrawer/appwidget/WidgetContentLoaderTest.kt new file mode 100644 index 00000000..eb7c52dc --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/appwidget/WidgetContentLoaderTest.kt @@ -0,0 +1,70 @@ +package de.psdev.devdrawer.appwidget + +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.fakes.FakeAppsService +import de.psdev.devdrawer.fakes.FakePackageFilterRepository +import de.psdev.devdrawer.fakes.FakeWidgetRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetContentLoaderTest { + + private val zeta = PackageHashInfo("com.example.zeta", firstInstallTime = 1, lastUpdateTime = 30, signatureHashSha256 = "key") + private val alpha = PackageHashInfo("com.example.alpha", firstInstallTime = 2, lastUpdateTime = 10, signatureHashSha256 = "key") + private val unrelated = PackageHashInfo("org.other", firstInstallTime = 3, lastUpdateTime = 20, signatureHashSha256 = "other") + private val appsService = FakeAppsService(mapOf(zeta to "Zeta", alpha to "Alpha", unrelated to "Unrelated")) + private val filters = FakePackageFilterRepository( + listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "p1")) + ) + + private fun loader(widget: Widget?, defaultSortOrder: SortOrder = SortOrder.LAST_UPDATED) = WidgetContentLoader( + widgetRepository = FakeWidgetRepository(listOfNotNull(widget)), + packageFilterRepository = filters, + appsService = appsService, + defaultSortOrder = { defaultSortOrder } + ) + + @Test + fun `given a widget without its own sort order, when loading, then matching apps use the default order`() = runTest { + // Given + val widget = Widget(id = 1, name = "Work", color = 0, profileId = "p1") + + // When + val content = loader(widget, defaultSortOrder = SortOrder.LAST_UPDATED).load(1) + + // Then + assertEquals(widget, content?.widget) + assertEquals(listOf("Zeta", "Alpha"), content?.apps?.map { it.name }) + } + + @Test + fun `given a widget with its own sort order, when loading, then it overrides the default`() = runTest { + // Given + val widget = Widget(id = 1, name = "Work", color = 0, profileId = "p1", sortOrder = SortOrder.NAME) + + // When + val content = loader(widget, defaultSortOrder = SortOrder.LAST_UPDATED).load(1) + + // Then + assertEquals(listOf("Alpha", "Zeta"), content?.apps?.map { it.name }) + } + + @Test + fun `given no widget with that id, when loading, then there is no content`() = runTest { + // Given / When + val content = loader(widget = null).load(1) + + // Then + assertNull(content) + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt b/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt new file mode 100644 index 00000000..60f8c8ed --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt @@ -0,0 +1,67 @@ +package de.psdev.devdrawer.appwidget.glance + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.testing.unit.runGlanceAppWidgetUnitTest +import androidx.glance.testing.unit.hasContentDescription +import androidx.glance.testing.unit.hasText +import androidx.test.core.app.ApplicationProvider +import de.psdev.devdrawer.database.WidgetHeaderColor +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class DevDrawerWidgetContentTest { + + private fun state(apps: List) = WidgetUiState( + appWidgetId = 5, + title = "Work apps", + headerColor = WidgetHeaderColor.AMBER, + updatedAt = "15:12", + apps = apps + ) + + @Test + fun `given a widget with no apps, when rendered, then it explains the empty list and offers setup`() = + runGlanceAppWidgetUnitTest { + // Given + setContext(ApplicationProvider.getApplicationContext()) + setAppWidgetSize(DpSize(300.dp, 300.dp)) + + // When + provideComposable { DevDrawerWidgetContent(state(apps = emptyList())) } + + // Then + onNode(hasText("Work apps")).assertExists() + onNode(hasText("No apps · updated 15:12")).assertExists() + onNode(hasText("No apps match this widget")).assertExists() + onNode(hasText("Choose apps")).assertExists() + } + + @Test + fun `given a widget with apps, when rendered, then each app shows its name, package and actions`() = + runGlanceAppWidgetUnitTest { + // Given + setContext(ApplicationProvider.getApplicationContext()) + setAppWidgetSize(DpSize(300.dp, 300.dp)) + val apps = listOf( + WidgetAppItem(name = "DevDrawer2", packageName = "de.psdev.devdrawer", icon = null), + WidgetAppItem(name = "Client", packageName = "com.example.client", icon = null) + ) + + // When + provideComposable { DevDrawerWidgetContent(state(apps)) } + + // Then + onNode(hasText("2 apps · updated 15:12")).assertExists() + onNode(hasText("DevDrawer2")).assertExists() + onNode(hasText("de.psdev.devdrawer")).assertExists() + onNode(hasContentDescription("Uninstall DevDrawer2")).assertExists() + onNode(hasContentDescription("App details for DevDrawer2")).assertExists() + onNode(hasText("No apps match this widget")).assertDoesNotExist() + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt new file mode 100644 index 00000000..bf2e4f00 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt @@ -0,0 +1,23 @@ +package de.psdev.devdrawer.fakes + +import android.graphics.drawable.ColorDrawable +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.PackageHashInfo + +/** Installed packages with display names; needs Robolectric for the icon drawables. */ +class FakeAppsService(private val apps: Map) : IAppsService { + + override suspend fun installedPackages(includeSystemApps: Boolean): List = apps.keys.toList() + + override suspend fun appInfos(packages: List): List = packages.map { info -> + AppInfo( + name = apps.getValue(info), + packageName = info.packageName, + appIcon = ColorDrawable(0), + firstInstallTime = info.firstInstallTime, + lastUpdateTime = info.lastUpdateTime, + signatureHashSha256 = info.signatureHashSha256 + ) + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakePackageFilterRepository.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakePackageFilterRepository.kt new file mode 100644 index 00000000..3eed7d94 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakePackageFilterRepository.kt @@ -0,0 +1,32 @@ +package de.psdev.devdrawer.fakes + +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class FakePackageFilterRepository(initialFilters: List = emptyList()) : IPackageFilterRepository { + + val filters = MutableStateFlow(initialFilters) + + override suspend fun getById(packageFilterId: String): PackageFilter? = filters.value.find { it.id == packageFilterId } + + override suspend fun delete(packageFilter: PackageFilter) { + filters.value = filters.value.filter { it.id != packageFilter.id } + } + + override suspend fun save(packageFilter: PackageFilter) { + filters.value = filters.value.filter { it.id != packageFilter.id } + packageFilter + } + + override suspend fun saveProfile(widgetProfile: WidgetProfile, filters: List) { + this.filters.value = this.filters.value.filter { it.profileId != widgetProfile.id } + filters + } + + override suspend fun findAllByProfile(profileId: String): List = + filters.value.filter { it.profileId == profileId } + + override fun allFiltersFlow(): Flow> = filters.map { it } +} diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt index 5e6eb726..07c5212b 100644 --- a/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt @@ -20,6 +20,8 @@ class FakeWidgetRepository(initialWidgets: List = emptyList()) : IWidget widgets.value = widgets.value.filter { it.id != widget.id } } + override suspend fun findById(widgetId: Int): Widget? = widgets.value.find { it.id == widgetId } + override suspend fun findWidgetsForProfile(profileId: String): List = widgets.value.filter { it.profileId == profileId } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 279cf0da..8a4e0945 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ playReview = "2.0.2" robolectric = "4.17" junit = "4.13.2" androidxComposeBom = "2026.09.00" +androidx-glance = "1.2.0" androidx-room = "2.8.5" androidx-work = "2.12.0" slf4jAndroidLogger = "1.0.5" @@ -52,6 +53,9 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-glance-appwidget-testing = { module = "androidx.glance:glance-appwidget-testing", version.ref = "androidx-glance" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } From 301fb0ddc742b88f026e2f0159afcb4749c8a328 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:01:33 +0200 Subject: [PATCH 03/15] Ask which apps a new widget shows, and fix placing from the launcher Placing a widget from the launcher's picker opened the widget editor for a widget that was never saved (only the pin flow saved one), and the configure activity never returned RESULT_OK. Now the configure intent opens a setup screen: "Which apps should this widget show?", answered with one of your apps (signature filter), a package pattern, or an existing profile, with a live count of the apps that will appear. Done creates the profile and filter, saves the widget named after the profile, returns RESULT_OK and closes. Leaving without finishing keeps the result cancelled. The widget's empty state opens the same setup for an existing widget. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/DevDrawerApp.kt | 7 +- .../java/de/psdev/devdrawer/DevDrawerHost.kt | 11 +- .../java/de/psdev/devdrawer/MainActivity.kt | 50 +++- .../java/de/psdev/devdrawer/Navigation.kt | 1 + .../main/java/de/psdev/devdrawer/Routes.kt | 3 + .../java/de/psdev/devdrawer/WidgetLaunch.kt | 17 ++ .../devdrawer/widgets/IWidgetRepository.kt | 2 + .../devdrawer/widgets/WidgetRepository.kt | 5 + .../widgets/ui/setup/WidgetSetupScreen.kt | 248 ++++++++++++++++++ .../widgets/ui/setup/WidgetSetupState.kt | 40 +++ .../widgets/ui/setup/WidgetSetupViewModel.kt | 156 +++++++++++ app/src/main/res/values-de/strings.xml | 23 ++ app/src/main/res/values/strings.xml | 23 ++ .../de/psdev/devdrawer/WidgetLaunchTest.kt | 45 ++++ .../psdev/devdrawer/fakes/FakeAppsService.kt | 8 +- .../devdrawer/fakes/FakeWidgetRepository.kt | 4 + .../ui/setup/WidgetSetupViewModelTest.kt | 172 ++++++++++++ 17 files changed, 807 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt index 0992fe83..fbaca72a 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.settings.SettingsViewModel import de.psdev.devdrawer.settings.ThemeSetting import de.psdev.devdrawer.ui.theme.DevDrawerTheme @@ -59,7 +60,8 @@ fun DevDrawerApp( viewModel: SettingsViewModel = hiltViewModel(), navigationState: NavigationState, navigator: Navigator, - trackingService: TrackingService + trackingService: TrackingService, + onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() } ) { val settings by viewModel.persistedSettings.collectAsState() val darkTheme = when (settings.themeSetting) { @@ -151,7 +153,8 @@ fun DevDrawerApp( navigationState = navigationState, navigator = navigator, menuCallback = setMenu, - modifier = Modifier.padding(innerPadding) + modifier = Modifier.padding(innerPadding), + onWidgetSetupDone = onWidgetSetupDone ) }, bottomBar = { diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt index 071c8301..c6a959fd 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt @@ -5,18 +5,21 @@ import androidx.compose.ui.Modifier import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import de.psdev.devdrawer.about.AboutScreen +import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.profiles.ui.editor.WidgetProfileEditor import de.psdev.devdrawer.profiles.ui.list.WidgetProfilesScreen import de.psdev.devdrawer.settings.SettingsScreen import de.psdev.devdrawer.widgets.ui.editor.WidgetEditorScreen import de.psdev.devdrawer.widgets.ui.list.WidgetListScreen +import de.psdev.devdrawer.widgets.ui.setup.WidgetSetupScreen @Composable fun DevDrawerHost( navigationState: NavigationState, navigator: Navigator, menuCallback: AppBarActionsProvider, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() } ) { val entryProvider = entryProvider { entry { @@ -53,6 +56,12 @@ fun DevDrawerHost( } ) } + entry { key -> + WidgetSetupScreen( + widgetId = key.widgetId, + onDone = onWidgetSetupDone + ) + } entry { key -> WidgetProfileEditor( profileId = key.id, diff --git a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt index f24c6fd0..2478cc7a 100644 --- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import de.psdev.devdrawer.database.Widget import kotlinx.coroutines.launch import mu.KLogging @@ -21,8 +22,17 @@ class MainActivity : BaseActivity() { companion object : KLogging() { /** Set by the widget's empty state: open the widget's setup instead of its editor. */ const val EXTRA_OPEN_SETUP = "open_setup" + + private const val STATE_EXTERNAL_SETUP_WIDGET_ID = "external_setup_widget_id" + private const val STATE_IS_CONFIGURATION = "is_configuration" } + /** The widget whose setup was opened from outside the app (launcher or widget); the activity closes when it's done. */ + private var externalSetupWidgetId: Int = INVALID_APPWIDGET_ID + + /** True while the launcher waits for this activity's result to place or reconfigure [externalSetupWidgetId]. */ + private var isConfiguration: Boolean = false + // Holds intents delivered via onNewIntent so they can be handled inside the Compose tree. private val newIntent = mutableStateOf(null) @@ -32,6 +42,11 @@ class MainActivity : BaseActivity() { public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (savedInstanceState != null) { + externalSetupWidgetId = savedInstanceState.getInt(STATE_EXTERNAL_SETUP_WIDGET_ID, INVALID_APPWIDGET_ID) + isConfiguration = savedInstanceState.getBoolean(STATE_IS_CONFIGURATION, false) + if (isConfiguration) setConfigurationResult(RESULT_CANCELED) + } enableEdgeToEdge() setContent { val navigationState = rememberNavigationState( @@ -59,7 +74,8 @@ class MainActivity : BaseActivity() { DevDrawerApp( navigationState = navigationState, navigator = navigator, - trackingService = trackingService + trackingService = trackingService, + onWidgetSetupDone = { widget -> onWidgetSetupDone(widget, navigator) } ) } lifecycleScope.launch { @@ -69,6 +85,12 @@ class MainActivity : BaseActivity() { } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(STATE_EXTERNAL_SETUP_WIDGET_ID, externalSetupWidgetId) + outState.putBoolean(STATE_IS_CONFIGURATION, isConfiguration) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -77,8 +99,30 @@ class MainActivity : BaseActivity() { private fun handleIntent(intent: Intent, navigator: Navigator) { val widgetId = intent.getIntExtra(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) - if (widgetId != INVALID_APPWIDGET_ID) { - navigator.navigate(WidgetEditorRoute(widgetId)) + val launch = widgetLaunchFor( + action = intent.action, + widgetId = widgetId, + openSetup = intent.getBooleanExtra(EXTRA_OPEN_SETUP, false) + ) ?: return + if (launch.route is WidgetSetupRoute) { + externalSetupWidgetId = widgetId + isConfiguration = launch.isConfiguration + // Leaving setup without finishing it removes a widget the launcher is placing. + if (isConfiguration) setConfigurationResult(RESULT_CANCELED) } + navigator.navigate(launch.route) + } + + private fun onWidgetSetupDone(widget: Widget, navigator: Navigator) { + if (widget.id == externalSetupWidgetId) { + if (isConfiguration) setConfigurationResult(RESULT_OK) + finish() + } else { + navigator.goBack() + } + } + + private fun setConfigurationResult(resultCode: Int) { + setResult(resultCode, Intent().putExtra(EXTRA_APPWIDGET_ID, externalSetupWidgetId)) } } diff --git a/app/src/main/java/de/psdev/devdrawer/Navigation.kt b/app/src/main/java/de/psdev/devdrawer/Navigation.kt index ff596dca..c919085c 100644 --- a/app/src/main/java/de/psdev/devdrawer/Navigation.kt +++ b/app/src/main/java/de/psdev/devdrawer/Navigation.kt @@ -10,6 +10,7 @@ val NavKey.title is WidgetListRoute -> R.string.widgets is WidgetProfilesRoute -> R.string.profiles is WidgetEditorRoute -> R.string.edit_widget + is WidgetSetupRoute -> R.string.setup_title is WidgetProfileEditorRoute -> R.string.edit_profile is AboutRoute -> R.string.app_info else -> R.string.app_name diff --git a/app/src/main/java/de/psdev/devdrawer/Routes.kt b/app/src/main/java/de/psdev/devdrawer/Routes.kt index fddd9249..bdf3f32d 100644 --- a/app/src/main/java/de/psdev/devdrawer/Routes.kt +++ b/app/src/main/java/de/psdev/devdrawer/Routes.kt @@ -18,5 +18,8 @@ data object AboutRoute : NavKey @Serializable data class WidgetEditorRoute(val id: Int) : NavKey +@Serializable +data class WidgetSetupRoute(val widgetId: Int) : NavKey + @Serializable data class WidgetProfileEditorRoute(val id: String) : NavKey diff --git a/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt b/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt new file mode 100644 index 00000000..93ddf122 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt @@ -0,0 +1,17 @@ +package de.psdev.devdrawer + +import android.appwidget.AppWidgetManager +import androidx.navigation3.runtime.NavKey + +/** + * Where an intent about a home-screen widget leads. [isConfiguration] is true when the launcher placed or + * reconfigures the widget and waits for this activity's result. + */ +data class WidgetLaunch(val route: NavKey, val isConfiguration: Boolean) + +fun widgetLaunchFor(action: String?, widgetId: Int, openSetup: Boolean): WidgetLaunch? = when { + widgetId == AppWidgetManager.INVALID_APPWIDGET_ID -> null + action == AppWidgetManager.ACTION_APPWIDGET_CONFIGURE -> WidgetLaunch(WidgetSetupRoute(widgetId), isConfiguration = true) + openSetup -> WidgetLaunch(WidgetSetupRoute(widgetId), isConfiguration = false) + else -> WidgetLaunch(WidgetEditorRoute(widgetId), isConfiguration = false) +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt index eded7111..2bfc13f1 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.Flow interface IWidgetRepository { fun widgetFlow(widgetId: Int): Flow suspend fun update(widget: Widget) + /** Inserts the widget, or updates it when one with its id is already saved. */ + suspend fun save(widget: Widget) suspend fun delete(widget: Widget) suspend fun findById(widgetId: Int): Widget? suspend fun findWidgetsForProfile(profileId: String): List diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt index fa6211d7..86a03938 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt @@ -20,6 +20,11 @@ class WidgetRepository @Inject constructor( UpdateReceiver.send(application) } + override suspend fun save(widget: Widget) { + devDrawerDatabase.widgetDao().insertOrUpdate(widget) + UpdateReceiver.send(application) + } + override suspend fun delete(widget: Widget) { devDrawerDatabase.widgetDao().delete(widget) UpdateReceiver.send(application) diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt new file mode 100644 index 00000000..a4f5139c --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt @@ -0,0 +1,248 @@ +package de.psdev.devdrawer.widgets.ui.setup + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.psdev.devdrawer.R +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.loading.LoadingView + +private const val COLLAPSED_APP_COUNT = 5 + +@Composable +fun WidgetSetupScreen( + widgetId: Int, + onDone: (Widget) -> Unit, + viewModel: WidgetSetupViewModel = hiltViewModel { + it.create(widgetId) + } +) { + val state by viewModel.state.collectAsStateWithLifecycle() + WidgetSetup( + state = state, + onSourceSelected = viewModel::onSourceSelected, + onAppSelected = viewModel::onAppSelected, + onPatternChanged = viewModel::onPatternChanged, + onProfileSelected = viewModel::onProfileSelected, + onDoneClick = { viewModel.finish(onDone) } + ) +} + +@Composable +fun WidgetSetup( + state: WidgetSetupState, + modifier: Modifier = Modifier, + onSourceSelected: (SetupSource) -> Unit = {}, + onAppSelected: (String) -> Unit = {}, + onPatternChanged: (String) -> Unit = {}, + onProfileSelected: (String) -> Unit = {}, + onDoneClick: () -> Unit = {} +) { + if (state.isLoading) { + LoadingView(modifier = modifier.fillMaxSize()) + return + } + var showAllApps by rememberSaveable { mutableStateOf(false) } + Column(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Column(modifier = Modifier.padding(bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = stringResource(R.string.setup_question), style = MaterialTheme.typography.headlineSmall) + Text( + text = stringResource(R.string.setup_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + item { + SourceOption( + title = stringResource(R.string.setup_source_my_apps), + summary = stringResource(R.string.setup_source_my_apps_summary), + selected = state.source == SetupSource.MY_APPS, + onClick = { onSourceSelected(SetupSource.MY_APPS) } + ) + } + if (state.source == SetupSource.MY_APPS) { + val visibleApps = if (showAllApps) state.apps else state.apps.take(COLLAPSED_APP_COUNT) + items(visibleApps, key = { it.packageName }) { app -> + AppOption(app = app, selected = app.packageName == state.selectedPackageName, onClick = { onAppSelected(app.packageName) }) + } + if (!showAllApps && state.apps.size > COLLAPSED_APP_COUNT) { + item { + TextButton(onClick = { showAllApps = true }) { + Text(stringResource(R.string.setup_show_all_apps, state.apps.size)) + } + } + } + } + item { + SourceOption( + title = stringResource(R.string.setup_source_pattern), + summary = stringResource(R.string.setup_source_pattern_summary), + selected = state.source == SetupSource.PATTERN, + onClick = { onSourceSelected(SetupSource.PATTERN) } + ) + } + if (state.source == SetupSource.PATTERN) { + item { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.pattern, + onValueChange = onPatternChanged, + singleLine = true, + label = { Text(stringResource(R.string.packagefilter)) }, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Ascii) + ) + } + } + item { + SourceOption( + title = stringResource(R.string.setup_source_profile), + summary = stringResource(R.string.setup_source_profile_summary), + selected = state.source == SetupSource.PROFILE, + onClick = { onSourceSelected(SetupSource.PROFILE) } + ) + } + if (state.source == SetupSource.PROFILE) { + if (state.profiles.isEmpty()) { + item { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.setup_no_profiles), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + items(state.profiles, key = { it.profile.id }) { option -> + ChoiceRow( + selected = option.profile.id == state.selectedProfileId, + onClick = { onProfileSelected(option.profile.id) }, + title = option.profile.name, + subtitle = pluralStringResource(R.plurals.widget_app_count, option.appCount, option.appCount) + ) + } + } + } + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = pluralStringResource(R.plurals.setup_match_count, state.matchCount, state.matchCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(enabled = state.canFinish, onClick = onDoneClick) { + Text(stringResource(R.string.setup_done)) + } + } + } +} + +@Composable +private fun SourceOption(title: String, summary: String, selected: Boolean, onClick: () -> Unit) { + Surface( + color = if (selected) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onClick, role = Role.RadioButton) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = selected, onClick = null) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text(text = summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun AppOption(app: SetupApp, selected: Boolean, onClick: () -> Unit) { + val subtitle = if (app.otherAppsWithSameKey > 0) { + pluralStringResource(R.plurals.setup_also_matches, app.otherAppsWithSameKey, app.otherAppsWithSameKey) + } else { + app.packageName + } + ChoiceRow(selected = selected, onClick = onClick, title = app.name, subtitle = subtitle) { + val icon = remember(app.icon) { app.icon?.toBitmap()?.asImageBitmap() } + if (icon != null) { + Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(40.dp)) + } else { + Spacer(Modifier.size(40.dp)) + } + } +} + +@Composable +private fun ChoiceRow( + selected: Boolean, + onClick: () -> Unit, + title: String, + subtitle: String, + leading: (@Composable () -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onClick, role = Role.RadioButton) + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton(selected = selected, onClick = null) + leading?.invoke() + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text(text = subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt new file mode 100644 index 00000000..b0333855 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt @@ -0,0 +1,40 @@ +package de.psdev.devdrawer.widgets.ui.setup + +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.database.WidgetProfile + +enum class SetupSource { MY_APPS, PATTERN, PROFILE } + +@Immutable +data class SetupApp( + val name: String, + val packageName: String, + val signatureHash: String, + /** Other installed apps signed with the same key, which a signature filter on this app also shows. */ + val otherAppsWithSameKey: Int, + val icon: Drawable? +) + +@Immutable +data class SetupProfile(val profile: WidgetProfile, val appCount: Int) + +@Immutable +data class WidgetSetupState( + val isLoading: Boolean = true, + val source: SetupSource = SetupSource.MY_APPS, + val apps: List = emptyList(), + val selectedPackageName: String? = null, + val pattern: String = "", + val profiles: List = emptyList(), + val selectedProfileId: String? = null, + /** How many installed apps the widget will list with the current choice. */ + val matchCount: Int = 0 +) { + val canFinish: Boolean + get() = when (source) { + SetupSource.MY_APPS -> selectedPackageName != null + SetupSource.PATTERN -> pattern.isNotBlank() + SetupSource.PROFILE -> selectedProfileId != null + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt new file mode 100644 index 00000000..f1803d18 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt @@ -0,0 +1,156 @@ +package de.psdev.devdrawer.widgets.ui.setup + +import android.app.Application +import android.graphics.Color +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.analytics.Events +import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.apps.matching +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import de.psdev.devdrawer.profiles.IWidgetProfileRepository +import de.psdev.devdrawer.widgets.IWidgetRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Asks which apps a widget should show when it is placed (or from its empty state), then creates the profile and + * filter that answer it and saves the widget. + */ +@HiltViewModel(assistedFactory = WidgetSetupViewModel.Factory::class) +class WidgetSetupViewModel @AssistedInject constructor( + @Assisted private val widgetId: Int, + private val application: Application, + private val widgetRepository: IWidgetRepository, + private val widgetProfileRepository: IWidgetProfileRepository, + private val packageFilterRepository: IPackageFilterRepository, + private val appsService: IAppsService, + private val trackingService: TrackingService +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(widgetId: Int): WidgetSetupViewModel + } + + private val mutableState = MutableStateFlow(WidgetSetupState()) + val state: StateFlow = mutableState.asStateFlow() + + private var installedPackages: List = emptyList() + private var filtersByProfile: Map> = emptyMap() + + init { + viewModelScope.launch { load() } + } + + private suspend fun load() { + installedPackages = appsService.installedPackages(includeSystemApps = true) + val keyCounts = installedPackages.groupingBy { it.signatureHashSha256 }.eachCount() + val ownApps = appsService.installedPackages(includeSystemApps = false) + .sortedByDescending { it.lastUpdateTime } + val apps = appsService.appInfos(ownApps).map { app -> + SetupApp( + name = app.name, + packageName = app.packageName, + signatureHash = app.signatureHashSha256, + otherAppsWithSameKey = (keyCounts[app.signatureHashSha256] ?: 1) - 1, + icon = app.appIcon + ) + } + val profiles = widgetProfileRepository.findAll() + filtersByProfile = profiles.associate { it.id to packageFilterRepository.findAllByProfile(it.id) } + val currentProfileId = widgetRepository.findById(widgetId)?.profileId + mutableState.update { state -> + state.copy( + isLoading = false, + apps = apps, + profiles = profiles.map { SetupProfile(it, installedPackages.matching(filtersByProfile[it.id].orEmpty()).size) }, + selectedProfileId = currentProfileId?.takeIf { id -> profiles.any { it.id == id } } + ).withMatchCount() + } + } + + fun onSourceSelected(source: SetupSource) { + mutableState.update { it.copy(source = source).withMatchCount() } + } + + fun onAppSelected(packageName: String) { + mutableState.update { it.copy(source = SetupSource.MY_APPS, selectedPackageName = packageName).withMatchCount() } + } + + fun onPatternChanged(pattern: String) { + mutableState.update { it.copy(source = SetupSource.PATTERN, pattern = pattern).withMatchCount() } + } + + fun onProfileSelected(profileId: String) { + mutableState.update { it.copy(source = SetupSource.PROFILE, selectedProfileId = profileId).withMatchCount() } + } + + /** Saves the choice; [onDone] gets the saved widget. Does nothing until the choice is complete. */ + fun finish(onDone: (Widget) -> Unit) { + val state = mutableState.value + if (!state.canFinish) return + viewModelScope.launch { + val profile = when (state.source) { + SetupSource.MY_APPS -> { + val app = state.apps.first { it.packageName == state.selectedPackageName } + createProfile( + name = application.getString(R.string.profile_name_signed_like, app.name), + filter = { profileId -> + PackageFilter(type = FilterType.SIGNATURE, filter = app.signatureHash, description = app.name, profileId = profileId) + } + ) + } + SetupSource.PATTERN -> { + val pattern = state.pattern.trim() + createProfile( + name = pattern, + filter = { profileId -> PackageFilter(type = FilterType.PACKAGE_NAME, filter = pattern, profileId = profileId) } + ) + } + SetupSource.PROFILE -> state.profiles.first { it.profile.id == state.selectedProfileId }.profile + } + val widget = widgetRepository.findById(widgetId)?.copy(name = profile.name, profileId = profile.id) + ?: Widget(id = widgetId, name = profile.name, color = Color.BLACK, profileId = profile.id) + widgetRepository.save(widget) + onDone(widget) + } + } + + private suspend fun createProfile(name: String, filter: (String) -> PackageFilter): WidgetProfile { + val profile = WidgetProfile(name = name) + widgetProfileRepository.create(profile) + packageFilterRepository.save(filter(profile.id)) + trackingService.trackAction(Events.PROFILE_CREATED) + return profile + } + + private fun WidgetSetupState.withMatchCount(): WidgetSetupState { + val filters = when (source) { + SetupSource.MY_APPS -> apps.firstOrNull { it.packageName == selectedPackageName }?.let { + listOf(PackageFilter(type = FilterType.SIGNATURE, filter = it.signatureHash, profileId = "")) + } + SetupSource.PATTERN -> pattern.trim().takeIf { it.isNotEmpty() }?.let { + listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = it, profileId = "")) + } + SetupSource.PROFILE -> selectedProfileId?.let { filtersByProfile[it] } + } + // A pattern being typed can be an invalid regex for a moment ("com.("): count no matches until it is valid. + val count = runCatching { installedPackages.matching(filters.orEmpty()).size }.getOrDefault(0) + return copy(matchCount = count) + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5adb4689..6328be3c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -103,4 +103,27 @@ %d App %d Apps + + + Widget einrichten + Welche Apps soll dieses Widget anzeigen? + Du kannst das später in den Einstellungen des Widgets ändern. + Meine Apps, nach Signaturschlüssel + Wähle eine deiner Apps. Jede App, die mit demselben Schlüssel signiert ist, erscheint. + Paketnamen-Muster + Zum Beispiel com.mycompany.* + Ein vorhandenes Profil + Die Apps eines anderen Widgets verwenden + Alle %1$d Apps anzeigen + Noch keine Profile + Fertig + Signiert wie %1$s + + Passt auch zu %d weiteren App + Passt auch zu %d weiteren Apps + + + %d App wird angezeigt + %d Apps werden angezeigt + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3caf2ac0..2e60ba44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,4 +143,27 @@ %d app %d apps + + + Set up widget + Which apps should this widget show? + You can change this later in the widget\'s settings. + My apps, by signing key + Pick one of your apps. Every app signed with the same key appears. + Package name pattern + For example com.mycompany.* + An existing profile + Reuse the apps of another widget + Show all %1$d apps + No profiles yet + Done + Signed like %1$s + + Also matches %d other app + Also matches %d other apps + + + %d app will appear + %d apps will appear + diff --git a/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt b/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt new file mode 100644 index 00000000..086f880f --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt @@ -0,0 +1,45 @@ +package de.psdev.devdrawer + +import android.appwidget.AppWidgetManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WidgetLaunchTest { + + @Test + fun `given the launcher configures a new widget, when resolving, then setup opens and a result is expected`() { + // Given / When + val launch = widgetLaunchFor(action = AppWidgetManager.ACTION_APPWIDGET_CONFIGURE, widgetId = 7, openSetup = false) + + // Then + assertEquals(WidgetLaunch(WidgetSetupRoute(7), isConfiguration = true), launch) + } + + @Test + fun `given the widget's empty state, when resolving, then setup opens without a result`() { + // Given / When + val launch = widgetLaunchFor(action = null, widgetId = 7, openSetup = true) + + // Then + assertEquals(WidgetLaunch(WidgetSetupRoute(7), isConfiguration = false), launch) + } + + @Test + fun `given the widget's settings button, when resolving, then the editor opens`() { + // Given / When + val launch = widgetLaunchFor(action = null, widgetId = 7, openSetup = false) + + // Then + assertEquals(WidgetLaunch(WidgetEditorRoute(7), isConfiguration = false), launch) + } + + @Test + fun `given no widget id, when resolving, then nothing widget-specific opens`() { + // Given / When + val launch = widgetLaunchFor(action = null, widgetId = AppWidgetManager.INVALID_APPWIDGET_ID, openSetup = false) + + // Then + assertNull(launch) + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt index bf2e4f00..652e795f 100644 --- a/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakeAppsService.kt @@ -6,9 +6,13 @@ import de.psdev.devdrawer.appwidget.AppInfo import de.psdev.devdrawer.appwidget.PackageHashInfo /** Installed packages with display names; needs Robolectric for the icon drawables. */ -class FakeAppsService(private val apps: Map) : IAppsService { +class FakeAppsService( + private val apps: Map, + private val systemPackages: Set = emptySet() +) : IAppsService { - override suspend fun installedPackages(includeSystemApps: Boolean): List = apps.keys.toList() + override suspend fun installedPackages(includeSystemApps: Boolean): List = + apps.keys.filter { includeSystemApps || it.packageName !in systemPackages } override suspend fun appInfos(packages: List): List = packages.map { info -> AppInfo( diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt index 07c5212b..cee9121a 100644 --- a/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt @@ -20,6 +20,10 @@ class FakeWidgetRepository(initialWidgets: List = emptyList()) : IWidget widgets.value = widgets.value.filter { it.id != widget.id } } + override suspend fun save(widget: Widget) { + widgets.value = widgets.value.filter { it.id != widget.id } + widget + } + override suspend fun findById(widgetId: Int): Widget? = widgets.value.find { it.id == widgetId } override suspend fun findWidgetsForProfile(profileId: String): List = diff --git a/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt new file mode 100644 index 00000000..98777086 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt @@ -0,0 +1,172 @@ +package de.psdev.devdrawer.widgets.ui.setup + +import de.psdev.devdrawer.MainDispatcherRule +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetHeaderColor +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.fakes.FakeAppsService +import de.psdev.devdrawer.fakes.FakePackageFilterRepository +import de.psdev.devdrawer.fakes.FakeWidgetProfileRepository +import de.psdev.devdrawer.fakes.FakeWidgetRepository +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetSetupViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val devDrawer = PackageHashInfo("de.psdev.devdrawer", firstInstallTime = 1, lastUpdateTime = 30, signatureHashSha256 = "key-a") + private val other = PackageHashInfo("com.yourcompany.other", firstInstallTime = 2, lastUpdateTime = 10, signatureHashSha256 = "key-a") + private val client = PackageHashInfo("com.example.client", firstInstallTime = 3, lastUpdateTime = 20, signatureHashSha256 = "key-b") + private val system = PackageHashInfo("com.android.settings", firstInstallTime = 0, lastUpdateTime = 40, signatureHashSha256 = "platform") + + private val appsService = FakeAppsService( + apps = mapOf(devDrawer to "DevDrawer2", other to "Other", client to "Client", system to "Settings"), + systemPackages = setOf(system.packageName) + ) + private val existingProfile = WidgetProfile(id = "existing", name = "Clients") + private val widgetRepository = FakeWidgetRepository() + private val profileRepository = FakeWidgetProfileRepository(listOf(existingProfile)) + private val filterRepository = FakePackageFilterRepository( + listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "existing")) + ) + + private fun createViewModel() = WidgetSetupViewModel( + widgetId = 5, + application = RuntimeEnvironment.getApplication(), + widgetRepository = widgetRepository, + widgetProfileRepository = profileRepository, + packageFilterRepository = filterRepository, + appsService = appsService, + trackingService = mockk(relaxed = true) + ) + + @Test + fun `given installed apps, when loaded, then your apps are listed newest first with their key mates and no system apps`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + + // When + advanceUntilIdle() + + // Then + val state = viewModel.state.value + assertFalse(state.isLoading) + assertEquals(listOf("DevDrawer2", "Client", "Other"), state.apps.map { it.name }) + assertEquals(1, state.apps.first { it.name == "DevDrawer2" }.otherAppsWithSameKey) + assertEquals(0, state.apps.first { it.name == "Client" }.otherAppsWithSameKey) + assertEquals(listOf("Clients"), state.profiles.map { it.profile.name }) + assertEquals(1, state.profiles.single().appCount) + } + + @Test + fun `given nothing chosen, when an app is picked, then the count covers every app with its key`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + assertFalse(viewModel.state.value.canFinish) + + // When + viewModel.onAppSelected(devDrawer.packageName) + advanceUntilIdle() + + // Then + assertEquals(2, viewModel.state.value.matchCount) + assertTrue(viewModel.state.value.canFinish) + } + + @Test + fun `given a picked app, when finishing, then a signature profile is created and the new widget uses it`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + viewModel.onAppSelected(devDrawer.packageName) + advanceUntilIdle() + + // When + var done: Widget? = null + viewModel.finish { done = it } + advanceUntilIdle() + + // Then + val profile = profileRepository.findAll().single { it.id != existingProfile.id } + assertEquals("Signed like DevDrawer2", profile.name) + val filter = filterRepository.findAllByProfile(profile.id).single() + assertEquals(FilterType.SIGNATURE, filter.type) + assertEquals("key-a", filter.filter) + assertEquals("DevDrawer2", filter.description) + val widget = widgetRepository.findById(5)!! + assertEquals(profile.id, widget.profileId) + assertEquals("Signed like DevDrawer2", widget.name) + assertEquals(widget, done) + } + + @Test + fun `given a package pattern, when finishing, then a pattern profile is created`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + viewModel.onSourceSelected(SetupSource.PATTERN) + viewModel.onPatternChanged("com.yourcompany.*") + advanceUntilIdle() + assertEquals(1, viewModel.state.value.matchCount) + + // When + viewModel.finish {} + advanceUntilIdle() + + // Then + val profile = profileRepository.findAll().single { it.id != existingProfile.id } + assertEquals("com.yourcompany.*", profile.name) + val filter = filterRepository.findAllByProfile(profile.id).single() + assertEquals(FilterType.PACKAGE_NAME, filter.type) + assertEquals("com.yourcompany.*", filter.filter) + } + + @Test + fun `given an existing widget, when reusing a profile, then the widget is reassigned and keeps its colour`() = runTest { + // Given + widgetRepository.save(Widget(id = 5, name = "Old", color = 0, profileId = "existing", headerColor = WidgetHeaderColor.DARK)) + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + viewModel.onSourceSelected(SetupSource.PROFILE) + viewModel.onProfileSelected(existingProfile.id) + advanceUntilIdle() + + // When + viewModel.finish {} + advanceUntilIdle() + + // Then + assertEquals(listOf(existingProfile), profileRepository.findAll()) + val widget = widgetRepository.findById(5)!! + assertEquals("existing", widget.profileId) + assertEquals("Clients", widget.name) + assertEquals(WidgetHeaderColor.DARK, widget.headerColor) + } +} From 63f85e42a14270003afda32a3ac8fba1d1f9bb69 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:06:54 +0200 Subject: [PATCH 04/15] Show what each widget lists, and edit it with a live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widgets list: each card shows the widget's header colour, its profile and how many apps it lists, instead of the internal "ID: 5". A widget whose profile matches nothing says "No apps match · Choose apps" and opens setup. Widget editor: a live preview of the widget, the curated header palette (wallpaper colours on Android 12+) instead of the 11 raw colours, profiles as radio rows with app counts so the selection is visible, and a per-widget sort order ("Default (…)" keeps the Settings value). Also fixes Settings' sort labels: they were mapped by enum position, so "Last updated" and "First installed" were swapped. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/DevDrawerHost.kt | 3 + .../psdev/devdrawer/appwidget/WidgetModule.kt | 9 +- .../devdrawer/database/RepositoryModule.kt | 5 + .../devdrawer/profiles/ProfileWithAppCount.kt | 8 + .../devdrawer/settings/ISortOrderSettings.kt | 18 + .../devdrawer/settings/SettingsScreen.kt | 4 +- .../devdrawer/settings/SortOrderLabel.kt | 11 + .../devdrawer/widgets/IWidgetRepository.kt | 1 + .../devdrawer/widgets/WidgetRepository.kt | 3 + .../devdrawer/widgets/ui/HeaderSwatch.kt | 30 ++ .../psdev/devdrawer/widgets/ui/WidgetCard.kt | 106 +++-- .../devdrawer/widgets/ui/editor/ColorGrid.kt | 101 ---- .../widgets/ui/editor/ColorSelectionDialog.kt | 64 --- .../widgets/ui/editor/WidgetEditorScreen.kt | 432 +++++++++++------- .../ui/editor/WidgetEditorViewModel.kt | 51 ++- .../ui/editor/WidgetEditorViewState.kt | 15 +- .../devdrawer/widgets/ui/list/WidgetList.kt | 9 +- .../widgets/ui/list/WidgetListScreen.kt | 34 +- .../ui/list/WidgetListScreenViewModel.kt | 42 +- .../widgets/ui/list/WidgetSummary.kt | 12 + .../widgets/ui/setup/WidgetSetupState.kt | 7 +- .../widgets/ui/setup/WidgetSetupViewModel.kt | 3 +- app/src/main/res/values-de/strings.xml | 18 +- app/src/main/res/values/strings.xml | 18 +- .../devdrawer/DialogStringResourcesTest.kt | 12 - .../devdrawer/fakes/FakeSortOrderSettings.kt | 8 + .../devdrawer/fakes/FakeWidgetRepository.kt | 2 + .../devdrawer/settings/SortOrderLabelTest.kt | 36 ++ .../ui/editor/WidgetEditorViewModelTest.kt | 151 +++--- .../ui/list/WidgetListScreenViewModelTest.kt | 68 +++ 30 files changed, 786 insertions(+), 495 deletions(-) create mode 100644 app/src/main/java/de/psdev/devdrawer/profiles/ProfileWithAppCount.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/settings/ISortOrderSettings.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/settings/SortOrderLabel.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/HeaderSwatch.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetSummary.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/fakes/FakeSortOrderSettings.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/settings/SortOrderLabelTest.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModelTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt index c6a959fd..3fb907a6 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt @@ -26,6 +26,9 @@ fun DevDrawerHost( WidgetListScreen( onWidgetClick = { widget -> navigator.navigate(WidgetEditorRoute(widget.id)) + }, + onChooseAppsClick = { widget -> + navigator.navigate(WidgetSetupRoute(widget.id)) } ) } diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt index 66343a21..35632166 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/WidgetModule.kt @@ -1,7 +1,5 @@ package de.psdev.devdrawer.appwidget -import android.app.Application -import android.content.SharedPreferences import dagger.Module import dagger.Provides import dagger.hilt.EntryPoint @@ -9,7 +7,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.psdev.devdrawer.apps.IAppsService import de.psdev.devdrawer.profiles.IPackageFilterRepository -import de.psdev.devdrawer.settings.defaultSortOrder +import de.psdev.devdrawer.settings.ISortOrderSettings import de.psdev.devdrawer.widgets.IWidgetRepository @Module @@ -18,16 +16,15 @@ class WidgetModule { @Provides fun widgetContentLoader( - application: Application, widgetRepository: IWidgetRepository, packageFilterRepository: IPackageFilterRepository, appsService: IAppsService, - sharedPreferences: SharedPreferences + sortOrderSettings: ISortOrderSettings ): WidgetContentLoader = WidgetContentLoader( widgetRepository = widgetRepository, packageFilterRepository = packageFilterRepository, appsService = appsService, - defaultSortOrder = { sharedPreferences.defaultSortOrder(application) } + defaultSortOrder = { sortOrderSettings.defaultSortOrder() } ) } diff --git a/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt b/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt index f73fd9c5..d8d0ca3f 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/RepositoryModule.kt @@ -10,6 +10,8 @@ import de.psdev.devdrawer.profiles.IPackageFilterRepository import de.psdev.devdrawer.profiles.IWidgetProfileRepository import de.psdev.devdrawer.profiles.PackageFilterRepository import de.psdev.devdrawer.profiles.WidgetProfileRepository +import de.psdev.devdrawer.settings.ISortOrderSettings +import de.psdev.devdrawer.settings.SharedPreferencesSortOrderSettings import de.psdev.devdrawer.widgets.IWidgetRepository import de.psdev.devdrawer.widgets.WidgetRepository @@ -23,6 +25,9 @@ abstract class RepositoryModule { @Binds abstract fun bindWidgetProfileRepository(impl: WidgetProfileRepository): IWidgetProfileRepository + @Binds + abstract fun bindSortOrderSettings(impl: SharedPreferencesSortOrderSettings): ISortOrderSettings + @Binds abstract fun bindAppsService(impl: AppsService): IAppsService diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ProfileWithAppCount.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ProfileWithAppCount.kt new file mode 100644 index 00000000..b6248d60 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ProfileWithAppCount.kt @@ -0,0 +1,8 @@ +package de.psdev.devdrawer.profiles + +import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.database.WidgetProfile + +/** A profile with the number of installed apps it currently matches. */ +@Immutable +data class ProfileWithAppCount(val profile: WidgetProfile, val appCount: Int) diff --git a/app/src/main/java/de/psdev/devdrawer/settings/ISortOrderSettings.kt b/app/src/main/java/de/psdev/devdrawer/settings/ISortOrderSettings.kt new file mode 100644 index 00000000..74946a0e --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/ISortOrderSettings.kt @@ -0,0 +1,18 @@ +package de.psdev.devdrawer.settings + +import android.app.Application +import android.content.SharedPreferences +import de.psdev.devdrawer.appwidget.SortOrder +import javax.inject.Inject + +/** The sort order Settings sets for widgets that don't have their own. */ +interface ISortOrderSettings { + fun defaultSortOrder(): SortOrder +} + +class SharedPreferencesSortOrderSettings @Inject constructor( + private val application: Application, + private val sharedPreferences: SharedPreferences +) : ISortOrderSettings { + override fun defaultSortOrder(): SortOrder = sharedPreferences.defaultSortOrder(application) +} 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 55dd0bb4..ee25e7bd 100644 --- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt @@ -84,9 +84,7 @@ fun SettingsScreen( val sortOrderLabels = stringArrayResource(id = R.array.sort_order_labels) ListPreference( label = stringResource(id = R.string.pref_sort_order_title), - values = SortOrder.entries.mapIndexed { index, sortOrder -> - sortOrder to sortOrderLabels[index] - }.toMap(), + values = SortOrder.entries.associateWith { it.label(sortOrderLabels) }, currentValue = settings.defaultSortOrder ) { onSortOrderChanged(it) diff --git a/app/src/main/java/de/psdev/devdrawer/settings/SortOrderLabel.kt b/app/src/main/java/de/psdev/devdrawer/settings/SortOrderLabel.kt new file mode 100644 index 00000000..20e85cd4 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/settings/SortOrderLabel.kt @@ -0,0 +1,11 @@ +package de.psdev.devdrawer.settings + +import de.psdev.devdrawer.appwidget.SortOrder + +/** + * The label for this order from `R.array.sort_order_labels`, which lists them in the order of + * `R.array.sort_order_values`, not in [SortOrder]'s declaration order. + */ +fun SortOrder.label(labels: Array): String = labels[LABEL_ORDER.indexOf(this)] + +private val LABEL_ORDER = listOf(SortOrder.LAST_UPDATED, SortOrder.FIRST_INSTALLED, SortOrder.NAME, SortOrder.PACKAGE_NAME) diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt index 2bfc13f1..2f57db9a 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/IWidgetRepository.kt @@ -4,6 +4,7 @@ import de.psdev.devdrawer.database.Widget import kotlinx.coroutines.flow.Flow interface IWidgetRepository { + fun widgetsFlow(): Flow> fun widgetFlow(widgetId: Int): Flow suspend fun update(widget: Widget) /** Inserts the widget, or updates it when one with its id is already saved. */ diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt index 86a03938..742ee68b 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/WidgetRepository.kt @@ -6,6 +6,7 @@ import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.receivers.UpdateReceiver import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow @Singleton class WidgetRepository @Inject constructor( @@ -13,6 +14,8 @@ class WidgetRepository @Inject constructor( private val devDrawerDatabase: DevDrawerDatabase ) : IWidgetRepository { + override fun widgetsFlow(): Flow> = devDrawerDatabase.widgetDao().findAllFlow() + override fun widgetFlow(widgetId: Int) = devDrawerDatabase.widgetDao().widgetWithIdObservable(widgetId) override suspend fun update(widget: Widget) { diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/HeaderSwatch.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/HeaderSwatch.kt new file mode 100644 index 00000000..2f00f149 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/HeaderSwatch.kt @@ -0,0 +1,30 @@ +package de.psdev.devdrawer.widgets.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import de.psdev.devdrawer.database.WidgetHeaderColor +import de.psdev.devdrawer.ui.theme.HeaderColorPair +import de.psdev.devdrawer.ui.theme.dark +import de.psdev.devdrawer.ui.theme.light + +/** + * The colours a widget header gets on the home screen: the palette entry for the system theme, or the + * wallpaper colours for [WidgetHeaderColor.DYNAMIC] (the app's own primary container before Android 12). + */ +@Composable +fun widgetHeaderColors(headerColor: WidgetHeaderColor): HeaderColorPair { + val darkTheme = isSystemInDarkTheme() + val fixed = if (darkTheme) headerColor.dark else headerColor.light + if (fixed != null) return fixed + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val context = LocalContext.current + val scheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + return HeaderColorPair(scheme.primaryContainer, scheme.onPrimaryContainer) + } + return HeaderColorPair(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt index c3085dd6..168caac2 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/WidgetCard.kt @@ -1,60 +1,114 @@ package de.psdev.devdrawer.widgets import android.content.res.Configuration -import android.graphics.Color -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box 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.wrapContentHeight -import androidx.compose.material3.* +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +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.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.psdev.devdrawer.R import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetHeaderColor import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.widgets.ui.list.WidgetSummary +import de.psdev.devdrawer.widgets.ui.widgetHeaderColors +/** + * A placed widget: its header colour, name, profile and app count. A widget that matches no apps says so; + * tapping it then opens setup instead of the editor. + */ @Composable fun WidgetCard( - widget: Widget, - onWidgetClick: (Widget) -> Unit = {} + summary: WidgetSummary, + onWidgetClick: (Widget) -> Unit = {}, + onChooseAppsClick: (Widget) -> Unit = {} ) { + val noApps = summary.appCount == 0 Card( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp) - .clickable { onWidgetClick(widget) } + .padding(horizontal = 8.dp, vertical = 4.dp), + onClick = { if (noApps) onChooseAppsClick(summary.widget) else onWidgetClick(summary.widget) } ) { - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(16.dp) + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyMedium, - text = widget.name - ) - Text( - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodySmall, - text = stringResource(id = R.string.widget_id_template, widget.id) + HeaderSwatchBox(summary.widget.headerColor) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + Text( + text = summary.widget.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (noApps) { + stringResource(R.string.widget_card_no_apps) + } else { + stringResource( + R.string.widget_card_summary, + summary.profileName, + pluralStringResource(R.plurals.widget_app_count, summary.appCount, summary.appCount) + ) + }, + style = MaterialTheme.typography.bodyMedium, + color = if (noApps) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } } +@Composable +private fun HeaderSwatchBox(headerColor: WidgetHeaderColor) { + val colors = widgetHeaderColors(headerColor) + Box( + modifier = Modifier + .size(40.dp) + .background(colors.container, MaterialTheme.shapes.small) + .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small) + ) +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun Preview_WidgetCard() { DevDrawerTheme { - WidgetCard(widget = Widget(1, "Test Widget", Color.BLACK, "")) + Column { + WidgetCard(WidgetSummary(Widget(1, "Work apps", 0, ""), profileName = "Signed like DevDrawer2", appCount = 7)) + WidgetCard(WidgetSummary(Widget(5, "Widget 5", 0, "", headerColor = WidgetHeaderColor.DARK), profileName = "Default", appCount = 0)) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt deleted file mode 100644 index 9495e6d4..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorGrid.kt +++ /dev/null @@ -1,101 +0,0 @@ -package de.psdev.devdrawer.widgets.ui.editor - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import de.psdev.devdrawer.ui.theme.DevDrawerTheme - -@Composable -fun ColorGrid( - initialColor: Int, - onColorClicked: (Int) -> Unit = {} -) { - var selectedColor by remember { mutableStateOf(initialColor) } - val colors = listOf( - android.graphics.Color.BLACK, - android.graphics.Color.DKGRAY, - android.graphics.Color.GRAY, - android.graphics.Color.LTGRAY, - android.graphics.Color.WHITE, - android.graphics.Color.RED, - android.graphics.Color.GREEN, - android.graphics.Color.BLUE, - android.graphics.Color.YELLOW, - android.graphics.Color.CYAN, - android.graphics.Color.MAGENTA - ) - LazyVerticalGrid( - modifier = Modifier.wrapContentHeight(), - columns = GridCells.Adaptive(minSize = 64.dp), - contentPadding = PaddingValues(8.dp) - ) { - items(colors) { color -> - val isSelectedColor = color == selectedColor - ColorBox( - isSelectedColor = isSelectedColor, - color = color - ) { - selectedColor = it - onColorClicked(it) - } - } - } -} - -@Composable -fun ColorBox( - modifier: Modifier = Modifier, - isSelectedColor: Boolean, - color: Int, - onColorClicked: (Int) -> Unit -) { - val cornerSize by animateDpAsState( - targetValue = if (isSelectedColor) 8.dp else 0.dp - ) - val borderWidth by animateDpAsState( - targetValue = if (isSelectedColor) 2.dp else 1.dp - ) - val borderColor by animateColorAsState( - targetValue = Color(if (isSelectedColor) android.graphics.Color.WHITE else android.graphics.Color.BLACK) - ) - val shape = RoundedCornerShape( - size = cornerSize - ) - Box(modifier = modifier - .padding(8.dp) - .requiredSize(48.dp) - .clip(shape) - .border( - width = borderWidth, - color = borderColor, - shape = shape - ) - .background(Color(color), shape = shape) - .clickable { - onColorClicked(color) - } - ) -} - -@Preview -@Composable -fun Preview_ColorGrid() { - DevDrawerTheme { - ColorGrid( - initialColor = android.graphics.Color.BLACK - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt deleted file mode 100644 index a75e0d04..00000000 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/ColorSelectionDialog.kt +++ /dev/null @@ -1,64 +0,0 @@ -package de.psdev.devdrawer.widgets.ui.editor - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.* -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import de.psdev.devdrawer.R -import de.psdev.devdrawer.ui.theme.DevDrawerTheme - -@Composable -fun ColorSelectionDialog( - initialColor: Int, - onColorSelectionChanged: (Int) -> Unit = {}, - onColorSelected: (Int) -> Unit = {}, - onDismiss: () -> Unit = {} -) { - var selectedColor by remember { mutableStateOf(initialColor) } - AlertDialog( - onDismissRequest = { }, - title = { - Text(text = stringResource(id = R.string.pick_widget_color)) - }, - text = { - ColorGrid( - initialColor = initialColor, - onColorClicked = { - selectedColor = it - onColorSelectionChanged(it) - } - ) - }, - dismissButton = { - TextButton(onClick = { - onDismiss() - }) { - Text(stringResource(id = R.string.cancel)) - } - }, - confirmButton = { - TextButton(onClick = { - onColorSelected(selectedColor) - }) { - Text(stringResource(id = R.string.apply)) - } - } - ) -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun Preview_ColorSelectionDialog() { - DevDrawerTheme { - Column { - ColorSelectionDialog( - initialColor = android.graphics.Color.BLACK - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt index cf33cae7..a3a0fda9 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt @@ -1,88 +1,84 @@ package de.psdev.devdrawer.widgets.ui.editor import android.content.res.Configuration +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues 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.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.outlined.Save -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface +import androidx.compose.material3.RadioButton 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.psdev.devdrawer.AppBarActionsProvider import de.psdev.devdrawer.ProvideMenu import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.SortOrder import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetHeaderColor import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.ProfileWithAppCount +import de.psdev.devdrawer.settings.ListPreference +import de.psdev.devdrawer.settings.label import de.psdev.devdrawer.ui.theme.DevDrawerTheme -import mu.KotlinLogging -import java.util.UUID - -private val logger = KotlinLogging.logger { } +import de.psdev.devdrawer.widgets.ui.widgetHeaderColors @Composable fun WidgetEditorScreen( id: Int, viewModel: WidgetEditorViewModel = hiltViewModel { - it.create( - id - ) + it.create(id) }, menuCallback: AppBarActionsProvider, onBack: () -> Unit, onEditWidgetProfile: (WidgetProfile) -> Unit -) { - WidgetEditorScreen( - viewModel = viewModel, - menuCallback = menuCallback, - onBack = onBack, - onEditWidgetProfile = onEditWidgetProfile, - onChangesSaved = {} - ) -} - -@Composable -fun WidgetEditorScreen( - viewModel: WidgetEditorViewModel, - menuCallback: AppBarActionsProvider, - onBack: () -> Unit, - onEditWidgetProfile: (WidgetProfile) -> Unit, - onChangesSaved: (Widget) -> Unit ) { val viewState by viewModel.state.collectAsStateWithLifecycle() - val persistedWidget = viewState.persistedWidget ProvideMenu(menuCallback, persistedWidget) { if (persistedWidget != null) { @@ -92,7 +88,7 @@ fun WidgetEditorScreen( }) { Icon( imageVector = Icons.Default.Delete, - contentDescription = null + contentDescription = stringResource(R.string.delete_widget) ) } } @@ -100,25 +96,21 @@ fun WidgetEditorScreen( WidgetEditor( viewState = viewState, onNameChange = viewModel::onNameChanged, - onColorSelected = { color -> - viewModel.onWidgetColorChanged(color) - }, + onHeaderColorSelected = viewModel::onHeaderColorSelected, + onSortOrderSelected = viewModel::onSortOrderSelected, onEditWidgetProfile = onEditWidgetProfile, onWidgetProfileSelected = viewModel::onWidgetProfileSelected, - onSaveChangesClick = { - viewModel.saveChanges() - persistedWidget?.let(onChangesSaved) - } + onSaveChangesClick = viewModel::saveChanges ) } -@OptIn(ExperimentalFoundationApi::class) @Composable fun WidgetEditor( modifier: Modifier = Modifier, viewState: WidgetEditorViewState, onNameChange: (String) -> Unit = {}, - onColorSelected: (Int) -> Unit = {}, + onHeaderColorSelected: (WidgetHeaderColor) -> Unit = {}, + onSortOrderSelected: (SortOrder?) -> Unit = {}, onEditWidgetProfile: (WidgetProfile) -> Unit = {}, onWidgetProfileSelected: (WidgetProfile) -> Unit = {}, onSaveChangesClick: () -> Unit = {} @@ -128,166 +120,254 @@ fun WidgetEditor( Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.size(64.dp)) } - } else { - var dialogState by remember { - mutableStateOf( - WidgetEditorDialogsState.None + return + } + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + WidgetPreview( + widget = widget, + apps = viewState.previewApps, + appCount = viewState.previewAppCount ) - } - Box(modifier = modifier.fillMaxSize()) { - Column { - Surface(modifier = Modifier.wrapContentHeight(), shadowElevation = 2.dp) { - Row( - modifier = Modifier - .wrapContentHeight() - .padding(8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - modifier = Modifier.weight(1f), - singleLine = true, - value = widget.name, - onValueChange = onNameChange, - label = { Text(text = stringResource(id = R.string.name)) } - ) - ColorBox(isSelectedColor = true, color = widget.color) { - dialogState = WidgetEditorDialogsState.ColorSelection(widget.color) - } - } - } - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(viewState.widgetProfiles) { widgetProfile -> - Card( - modifier = Modifier.combinedClickable( - onLongClick = { - onEditWidgetProfile(widgetProfile) - }, - onClick = { onWidgetProfileSelected(widgetProfile) } - ) - ) { - Row( - modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(modifier = Modifier.weight(1f), text = widgetProfile.name) - IconButton(onClick = { - onEditWidgetProfile(widgetProfile) - }) { - Icon( - imageVector = Icons.Filled.Edit, - contentDescription = stringResource(id = R.string.edit_profile) - ) - } - } - } - } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + value = widget.name, + onValueChange = onNameChange, + label = { Text(text = stringResource(id = R.string.name)) } + ) + HeaderColorPicker(selected = widget.headerColor, onSelected = onHeaderColorSelected) + SectionTitle(stringResource(R.string.editor_apps)) + Column(modifier = Modifier.selectableGroup()) { + viewState.profiles.forEach { option -> + ProfileRow( + option = option, + selected = option.profile.id == widget.profileId, + onSelected = { onWidgetProfileSelected(option.profile) }, + onEdit = { onEditWidgetProfile(option.profile) } + ) } } - AnimatedVisibility( - visible = viewState.persistedWidget != viewState.editableWidget, - modifier = Modifier.align(Alignment.BottomEnd), - enter = fadeIn(), - exit = fadeOut() + SortOrderPreference( + current = widget.sortOrder, + defaultSortOrder = viewState.defaultSortOrder, + onSelected = onSortOrderSelected + ) + } + AnimatedVisibility( + visible = viewState.isDirty, + modifier = Modifier.align(Alignment.BottomEnd), + enter = fadeIn(), + exit = fadeOut() + ) { + FloatingActionButton( + onClick = onSaveChangesClick, + modifier = Modifier.padding(end = 16.dp, bottom = 16.dp) ) { - FloatingActionButton( - onClick = onSaveChangesClick, - modifier = Modifier.padding(end = 16.dp, bottom = 16.dp) - ) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(id = R.string.save) + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(id = R.string.save) + ) + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text(text = text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) +} + +/** The widget as the home screen will show it: header, then the first apps it lists. */ +@Composable +private fun WidgetPreview(widget: Widget, apps: List, appCount: Int) { + val header = widgetHeaderColors(widget.headerColor) + val previewDescription = stringResource(R.string.widget_preview) + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) + .padding(16.dp) + .semantics { contentDescription = previewDescription } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(header.container, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = widget.name, + color = header.content, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (appCount == 0) { + stringResource(R.string.widget_no_apps) + } else { + pluralStringResource(R.plurals.widget_app_count, appCount, appCount) + }, + color = header.content, + style = MaterialTheme.typography.bodySmall ) } + Icon(imageVector = Icons.Default.Refresh, contentDescription = null, tint = header.content) } - } - when (val state = dialogState) { - WidgetEditorDialogsState.None -> Unit - is WidgetEditorDialogsState.ColorSelection -> ColorSelectionDialog( - initialColor = state.currentColor, - onColorSelected = { - onColorSelected(it) - dialogState = WidgetEditorDialogsState.None - }, - onDismiss = { - dialogState = WidgetEditorDialogsState.None + if (apps.isEmpty()) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.widget_empty_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + apps.forEach { app -> + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val icon = remember(app.packageName) { app.appIcon.toBitmap().asImageBitmap() } + Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(32.dp)) + Text( + modifier = Modifier.padding(start = 12.dp), + text = app.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - ) + } } } } -sealed class WidgetEditorDialogsState { - data object None : WidgetEditorDialogsState() - data class ColorSelection( - val currentColor: Int - ) : WidgetEditorDialogsState() -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun Preview_WidgetEditor_Loading() { - DevDrawerTheme { - WidgetEditor( - viewState = WidgetEditorViewState.Empty +private fun HeaderColorPicker(selected: WidgetHeaderColor, onSelected: (WidgetHeaderColor) -> Unit) { + val options = WidgetHeaderColor.entries.filter { + it != WidgetHeaderColor.DYNAMIC || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + } + val labels = mapOf( + WidgetHeaderColor.AMBER to stringResource(R.string.header_color_amber), + WidgetHeaderColor.NEUTRAL to stringResource(R.string.header_color_neutral), + WidgetHeaderColor.DARK to stringResource(R.string.header_color_dark), + WidgetHeaderColor.DYNAMIC to stringResource(R.string.header_color_dynamic) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .selectableGroup(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.header_color), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary ) + options.forEach { option -> + val colors = widgetHeaderColors(option) + val isSelected = option == selected + Box( + modifier = Modifier + .size(48.dp) + .selectable(selected = isSelected, onClick = { onSelected(option) }, role = Role.RadioButton) + .semantics { contentDescription = labels.getValue(option) }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(40.dp) + .background(colors.container, MaterialTheme.shapes.small) + .border( + width = if (isSelected) 3.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.small + ), + contentAlignment = Alignment.Center + ) { + if (option == WidgetHeaderColor.DYNAMIC) { + Icon(imageVector = Icons.Default.Palette, contentDescription = null, tint = colors.content) + } + } + } + } } } -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun Preview_WidgetEditor_Loaded() { - val widgetProfile = WidgetProfile( - id = UUID.randomUUID().toString(), - name = "Test widget profile" - ) - val widget = Widget( - id = 1, - name = "Test widget", - color = android.graphics.Color.YELLOW, - profileId = widgetProfile.id - ) - DevDrawerTheme { - WidgetEditor( - viewState = WidgetEditorViewState( - persistedWidget = widget, - widgetProfiles = listOf(widgetProfile), - editableWidget = widget +private fun ProfileRow(option: ProfileWithAppCount, selected: Boolean, onSelected: () -> Unit, onEdit: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onSelected, role = Role.RadioButton) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = selected, onClick = null) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text(text = option.profile.name, style = MaterialTheme.typography.bodyLarge) + Text( + text = pluralStringResource(R.plurals.widget_app_count, option.appCount, option.appCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - ) + } + IconButton(onClick = onEdit) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.edit_profile_named, option.profile.name) + ) + } } } +@Composable +private fun SortOrderPreference(current: SortOrder?, defaultSortOrder: SortOrder, onSelected: (SortOrder?) -> Unit) { + val labels = stringArrayResource(R.array.sort_order_labels) + val labelFor = { sortOrder: SortOrder -> sortOrder.label(labels) } + val values: Map = + mapOf(null to stringResource(R.string.sort_default, labelFor(defaultSortOrder))) + + SortOrder.entries.associateWith { labelFor(it) } + ListPreference( + label = stringResource(R.string.sort_by), + values = values, + currentValue = current, + onClick = onSelected + ) +} + @Preview(showSystemUi = true) @Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun Preview_WidgetEditor_Loaded_Changed() { - val widgetProfile = WidgetProfile( - id = UUID.randomUUID().toString(), - name = "Test widget profile" - ) - val widgetProfile2 = WidgetProfile( - id = UUID.randomUUID().toString(), - name = "Test widget profile 2" - ) - val widget = Widget( - id = 1, - name = "Test widget", - color = android.graphics.Color.YELLOW, - profileId = widgetProfile.id - ) +fun Preview_WidgetEditor_Loaded() { + val widgetProfile = WidgetProfile(id = "p1", name = "Signed like DevDrawer2") + val widget = Widget(id = 1, name = "Work apps", color = 0, profileId = widgetProfile.id) DevDrawerTheme { WidgetEditor( viewState = WidgetEditorViewState( persistedWidget = widget, - widgetProfiles = listOf(widgetProfile, widgetProfile2), - editableWidget = widget.copy(profileId = widgetProfile2.id) + editableWidget = widget.copy(headerColor = WidgetHeaderColor.DARK), + profiles = listOf(ProfileWithAppCount(widgetProfile, 7), ProfileWithAppCount(WidgetProfile(name = "Default"), 0)) ) ) } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt index f75f6400..824f45e2 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt @@ -8,9 +8,18 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import de.psdev.devdrawer.analytics.Events import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.apps.comparator +import de.psdev.devdrawer.apps.matching +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.appwidget.SortOrder import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetHeaderColor import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.IPackageFilterRepository import de.psdev.devdrawer.profiles.IWidgetProfileRepository +import de.psdev.devdrawer.profiles.ProfileWithAppCount +import de.psdev.devdrawer.settings.ISortOrderSettings import de.psdev.devdrawer.widgets.IWidgetRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -26,7 +35,10 @@ import kotlinx.coroutines.launch class WidgetEditorViewModel @AssistedInject constructor( @Assisted private val widgetId: Int, private val widgetRepository: IWidgetRepository, - private val widgetProfileRepository: IWidgetProfileRepository, + widgetProfileRepository: IWidgetProfileRepository, + packageFilterRepository: IPackageFilterRepository, + private val appsService: IAppsService, + private val sortOrderSettings: ISortOrderSettings, private val trackingService: TrackingService ) : ViewModel() { @@ -36,6 +48,7 @@ class WidgetEditorViewModel @AssistedInject constructor( } private val editableWidgetState: MutableStateFlow = MutableStateFlow(null) + private val installedPackages: MutableStateFlow> = MutableStateFlow(emptyList()) init { // Initialise the editable copy from the persisted widget on first load. @@ -44,17 +57,32 @@ class WidgetEditorViewModel @AssistedInject constructor( val initial = widgetRepository.widgetFlow(widgetId).filterNotNull().first() editableWidgetState.value = initial } + viewModelScope.launch { + installedPackages.value = appsService.installedPackages(includeSystemApps = true) + } } val state: StateFlow = combine( widgetRepository.widgetFlow(widgetId), widgetProfileRepository.widgetProfilesFlow(), - editableWidgetState - ) { persistedWidget, widgetProfiles, editableWidget -> + packageFilterRepository.allFiltersFlow(), + editableWidgetState, + installedPackages + ) { persistedWidget, widgetProfiles, filters, editableWidget, packages -> + val filtersByProfile = filters.groupBy { it.profileId } + val currentWidget = editableWidget ?: persistedWidget + val defaultSortOrder = sortOrderSettings.defaultSortOrder() + val matched = currentWidget?.let { packages.matching(filtersByProfile[it.profileId].orEmpty()) }.orEmpty() + val sortOrder = currentWidget?.sortOrder ?: defaultSortOrder WidgetEditorViewState( persistedWidget = persistedWidget, - widgetProfiles = widgetProfiles, - editableWidget = editableWidget ?: persistedWidget + editableWidget = currentWidget, + profiles = widgetProfiles.map { profile -> + ProfileWithAppCount(profile, packages.matching(filtersByProfile[profile.id].orEmpty()).size) + }, + previewApps = appsService.appInfos(matched).sortedWith(sortOrder.comparator()).take(PREVIEW_APP_COUNT), + previewAppCount = matched.size, + defaultSortOrder = defaultSortOrder ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetEditorViewState.Empty) @@ -62,8 +90,13 @@ class WidgetEditorViewModel @AssistedInject constructor( editableWidgetState.update { it?.copy(name = newName) } } - fun onWidgetColorChanged(color: Int) { - editableWidgetState.update { it?.copy(color = color) } + fun onHeaderColorSelected(headerColor: WidgetHeaderColor) { + editableWidgetState.update { it?.copy(headerColor = headerColor) } + } + + /** Null resets the widget to the sort order from Settings. */ + fun onSortOrderSelected(sortOrder: SortOrder?) { + editableWidgetState.update { it?.copy(sortOrder = sortOrder) } } fun onWidgetProfileSelected(widgetProfile: WidgetProfile) { @@ -84,4 +117,8 @@ class WidgetEditorViewModel @AssistedInject constructor( trackingService.trackAction(Events.WIDGET_DELETED) } } + + private companion object { + const val PREVIEW_APP_COUNT = 3 + } } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt index 0f48e329..7cdf51bb 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt @@ -1,15 +1,26 @@ package de.psdev.devdrawer.widgets.ui.editor import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.appwidget.SortOrder import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.ProfileWithAppCount @Immutable data class WidgetEditorViewState( val persistedWidget: Widget? = null, val editableWidget: Widget? = null, - val widgetProfiles: List = emptyList() + val profiles: List = emptyList(), + /** The first apps the widget would list with the edited settings. */ + val previewApps: List = emptyList(), + /** How many apps the widget would list with the edited settings. */ + val previewAppCount: Int = 0, + /** The Settings sort order, used while the widget has none of its own. */ + val defaultSortOrder: SortOrder = SortOrder.LAST_UPDATED ) { + val isDirty: Boolean + get() = editableWidget != null && editableWidget != persistedWidget + companion object { val Empty = WidgetEditorViewState() } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt index 363fd38a..82f69f49 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetList.kt @@ -16,9 +16,10 @@ import de.psdev.devdrawer.widgets.WidgetCard @Composable fun WidgetList( - widgets: List, + widgets: List, modifier: Modifier = Modifier, onWidgetClick: (Widget) -> Unit = {}, + onChooseAppsClick: (Widget) -> Unit = {}, contentPadding: PaddingValues = PaddingValues(0.dp) ) { LazyColumn( @@ -27,8 +28,8 @@ fun WidgetList( .fillMaxHeight(), contentPadding = contentPadding, ) { - items(widgets, key = { it.id }) { widget -> - WidgetCard(widget = widget, onWidgetClick = onWidgetClick) + items(widgets, key = { it.widget.id }) { summary -> + WidgetCard(summary = summary, onWidgetClick = onWidgetClick, onChooseAppsClick = onChooseAppsClick) } } } @@ -40,4 +41,4 @@ fun Preview_WidgetList() { DevDrawerTheme { WidgetList(widgets = testWidgets()) } -} \ 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 6c58fc49..61ee8633 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 @@ -39,13 +39,15 @@ import java.util.UUID @Composable fun WidgetListScreen( widgetListScreenViewModel: WidgetListScreenViewModel = hiltViewModel(), - onWidgetClick: (Widget) -> Unit + onWidgetClick: (Widget) -> Unit, + onChooseAppsClick: (Widget) -> Unit ) { val context = LocalContext.current val state by widgetListScreenViewModel.state.collectAsStateWithLifecycle() WidgetListScreen( state = state, onWidgetClick = onWidgetClick, + onChooseAppsClick = onChooseAppsClick, onRequestPinWidgetClick = { widgetListScreenViewModel.requestAppWidgetPinning(context) } @@ -56,6 +58,7 @@ fun WidgetListScreen( fun WidgetListScreen( state: WidgetListScreenState, onWidgetClick: (Widget) -> Unit = {}, + onChooseAppsClick: (Widget) -> Unit = {}, onRequestPinWidgetClick: () -> Unit = {} ) { when (state) { @@ -102,6 +105,7 @@ fun WidgetListScreen( WidgetList( widgets = widgets, onWidgetClick = onWidgetClick, + onChooseAppsClick = onChooseAppsClick, contentPadding = PaddingValues(bottom = 80.dp) ) if (state.isRequestPinAppWidgetSupported) { @@ -111,7 +115,7 @@ fun WidgetListScreen( .align(Alignment.BottomEnd) .padding(end = 16.dp, bottom = 16.dp) ) { - Icon(imageVector = Icons.Outlined.Add, contentDescription = "Pin new widget") + Icon(imageVector = Icons.Outlined.Add, contentDescription = stringResource(R.string.pin_new_widget)) } } } @@ -123,7 +127,7 @@ fun WidgetListScreen( sealed class WidgetListScreenState { data object Loading : WidgetListScreenState() data class Loaded( - val widgets: List, + val widgets: List, val isRequestPinAppWidgetSupported: Boolean = false ) : WidgetListScreenState() } @@ -169,7 +173,7 @@ fun Preview_WidgetListScreen_Empty_SupportsPinning() { @Composable fun Preview_WidgetListScreen_NotEmpty() { DevDrawerTheme { - WidgetListScreen(WidgetListScreenState.Loaded(emptyList())) + WidgetListScreen(WidgetListScreenState.Loaded(testWidgets())) } } @@ -178,21 +182,19 @@ fun Preview_WidgetListScreen_NotEmpty() { @Composable fun Preview_WidgetListScreen_NotEmpty_SupportsPinning() { DevDrawerTheme { - WidgetListScreen(WidgetListScreenState.Loaded(emptyList(), true)) + WidgetListScreen(WidgetListScreenState.Loaded(testWidgets(), true)) } } -fun testWidgets(): List = listOf( - Widget( - id = 1, - name = "Test Widget", - color = Color.BLACK, - profileId = UUID.randomUUID().toString() +fun testWidgets(): List = listOf( + WidgetSummary( + widget = Widget(id = 1, name = "Work apps", color = Color.BLACK, profileId = UUID.randomUUID().toString()), + profileName = "Signed like DevDrawer2", + appCount = 7 ), - Widget( - id = 2, - name = "Test Widget 2", - color = Color.BLACK, - profileId = UUID.randomUUID().toString() + WidgetSummary( + widget = Widget(id = 2, name = "Widget 2", color = Color.BLACK, profileId = UUID.randomUUID().toString()), + profileName = "Default", + appCount = 0 ) ) diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt index 5f9b778a..448430c8 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt @@ -11,29 +11,47 @@ import androidx.core.os.bundleOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.apps.matching import de.psdev.devdrawer.appwidget.DDWidgetProvider -import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.profiles.IPackageFilterRepository +import de.psdev.devdrawer.profiles.IWidgetProfileRepository import de.psdev.devdrawer.receivers.PinWidgetSuccessReceiver +import de.psdev.devdrawer.widgets.IWidgetRepository import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class WidgetListScreenViewModel @Inject constructor( private val application: Application, - database: DevDrawerDatabase + widgetRepository: IWidgetRepository, + widgetProfileRepository: IWidgetProfileRepository, + packageFilterRepository: IPackageFilterRepository, + private val appsService: IAppsService ) : ViewModel() { - val state = database.widgetDao().findAllFlow() - .map { widgets -> - val appWidgetManager: AppWidgetManager? = application.getSystemService() - WidgetListScreenState.Loaded( - widgets = widgets, - isRequestPinAppWidgetSupported = appWidgetManager?.isRequestPinAppWidgetSupported == true - ) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetListScreenState.Loading) + val state = combine( + widgetRepository.widgetsFlow(), + widgetProfileRepository.widgetProfilesFlow(), + packageFilterRepository.allFiltersFlow() + ) { widgets, profiles, filters -> + val packages = appsService.installedPackages(includeSystemApps = true) + val profilesById = profiles.associateBy { it.id } + val filtersByProfile = filters.groupBy { it.profileId } + val appWidgetManager: AppWidgetManager? = application.getSystemService() + WidgetListScreenState.Loaded( + widgets = widgets.map { widget -> + WidgetSummary( + widget = widget, + profileName = profilesById[widget.profileId]?.name.orEmpty(), + appCount = packages.matching(filtersByProfile[widget.profileId].orEmpty()).size + ) + }, + isRequestPinAppWidgetSupported = appWidgetManager?.isRequestPinAppWidgetSupported == true + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetListScreenState.Loading) @SuppressLint("InlinedApi") fun requestAppWidgetPinning(context: Context) { diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetSummary.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetSummary.kt new file mode 100644 index 00000000..8ba36e5f --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetSummary.kt @@ -0,0 +1,12 @@ +package de.psdev.devdrawer.widgets.ui.list + +import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.database.Widget + +/** A widget as the Widgets list shows it: with its profile's name and how many apps it lists. */ +@Immutable +data class WidgetSummary( + val widget: Widget, + val profileName: String, + val appCount: Int +) diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt index b0333855..51fff58d 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt @@ -2,7 +2,7 @@ package de.psdev.devdrawer.widgets.ui.setup import android.graphics.drawable.Drawable import androidx.compose.runtime.Immutable -import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.profiles.ProfileWithAppCount enum class SetupSource { MY_APPS, PATTERN, PROFILE } @@ -16,9 +16,6 @@ data class SetupApp( val icon: Drawable? ) -@Immutable -data class SetupProfile(val profile: WidgetProfile, val appCount: Int) - @Immutable data class WidgetSetupState( val isLoading: Boolean = true, @@ -26,7 +23,7 @@ data class WidgetSetupState( val apps: List = emptyList(), val selectedPackageName: String? = null, val pattern: String = "", - val profiles: List = emptyList(), + val profiles: List = emptyList(), val selectedProfileId: String? = null, /** How many installed apps the widget will list with the current choice. */ val matchCount: Int = 0 diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt index f1803d18..7dc5908d 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt @@ -20,6 +20,7 @@ import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.profiles.IPackageFilterRepository import de.psdev.devdrawer.profiles.IWidgetProfileRepository +import de.psdev.devdrawer.profiles.ProfileWithAppCount import de.psdev.devdrawer.widgets.IWidgetRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -78,7 +79,7 @@ class WidgetSetupViewModel @AssistedInject constructor( state.copy( isLoading = false, apps = apps, - profiles = profiles.map { SetupProfile(it, installedPackages.matching(filtersByProfile[it.id].orEmpty()).size) }, + profiles = profiles.map { ProfileWithAppCount(it, installedPackages.matching(filtersByProfile[it.id].orEmpty()).size) }, selectedProfileId = currentProfileId?.takeIf { id -> profiles.any { it.id == id } } ).withMatchCount() } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6328be3c..034d9b39 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -35,13 +35,11 @@ Abbrechen Keine Widgets erstellt Widget hinzufügen - Widget-Farbe wählen OK Profil kann nicht gelöscht werden, da es noch von Widgets verwendet wird Nein Ja ComposeActivity - ID: %1$d Neues Profil erstellen Wird geladen… Zuletzt geändert @@ -126,4 +124,20 @@ %d App wird angezeigt %d Apps werden angezeigt + + + Profil „%1$s“ · %2$s + Keine App passt · Apps auswählen + Widget zum Startbildschirm hinzufügen + Widget löschen + Widget-Vorschau + Kopfzeilenfarbe + Bernstein + Neutral + Dunkel + Farben des Hintergrundbilds + Apps + Sortieren nach + Standard (%1$s) + Profil %1$s bearbeiten diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e60ba44..cd89f4f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,7 +70,6 @@ Cancel No widgets created Add widget - Pick widget color OK Cannot delete profile, still being used by widgets No @@ -78,7 +77,6 @@ ComposeActivity - ID: %1$d Create new profile @@ -166,4 +164,20 @@ %d app will appear %d apps will appear + + + Profile “%1$s” · %2$s + No apps match · Choose apps + Add widget to home screen + Delete widget + Widget preview + Header colour + Amber + Neutral + Dark + Wallpaper colours + Apps + Sort by + Default (%1$s) + Edit profile %1$s diff --git a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt index cfd314f3..0226c1fd 100644 --- a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt @@ -18,7 +18,6 @@ import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel import de.psdev.devdrawer.profiles.ui.list.DeleteProfileDialog import de.psdev.devdrawer.settings.ListPreference import de.psdev.devdrawer.ui.theme.DevDrawerTheme -import de.psdev.devdrawer.widgets.ui.editor.ColorSelectionDialog import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -79,17 +78,6 @@ class DialogStringResourcesTest { composeTestRule.onNodeWithText("Das Profil Arbeit wird verwendet von:", substring = true).assertExists() } - @Test - fun `given a German locale, when showing the color selection dialog, then it reads German`() { - // Given / When - composeTestRule.setContent { - DevDrawerTheme { ColorSelectionDialog(initialColor = Color.BLACK) } - } - - // Then - assertTexts("Widget-Farbe wählen") - } - @Test fun `given a German locale, when opening a list preference, then the default dialog title reads German`() { // Given diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakeSortOrderSettings.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakeSortOrderSettings.kt new file mode 100644 index 00000000..cec675d2 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakeSortOrderSettings.kt @@ -0,0 +1,8 @@ +package de.psdev.devdrawer.fakes + +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.settings.ISortOrderSettings + +class FakeSortOrderSettings(var sortOrder: SortOrder = SortOrder.LAST_UPDATED) : ISortOrderSettings { + override fun defaultSortOrder(): SortOrder = sortOrder +} diff --git a/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt b/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt index cee9121a..e143e1d4 100644 --- a/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt +++ b/app/src/test/java/de/psdev/devdrawer/fakes/FakeWidgetRepository.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.map class FakeWidgetRepository(initialWidgets: List = emptyList()) : IWidgetRepository { private val widgets = MutableStateFlow(initialWidgets) + override fun widgetsFlow(): Flow> = widgets + override fun widgetFlow(widgetId: Int): Flow = widgets.map { list -> list.find { it.id == widgetId } } diff --git a/app/src/test/java/de/psdev/devdrawer/settings/SortOrderLabelTest.kt b/app/src/test/java/de/psdev/devdrawer/settings/SortOrderLabelTest.kt new file mode 100644 index 00000000..e9acbb1f --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/settings/SortOrderLabelTest.kt @@ -0,0 +1,36 @@ +package de.psdev.devdrawer.settings + +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.SortOrder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class SortOrderLabelTest { + + @Test + fun `given each sort order, when labelling it, then the label names that order`() { + // Given + val labels = RuntimeEnvironment.getApplication().resources.getStringArray(R.array.sort_order_labels) + + // When + val result = SortOrder.entries.associateWith { it.label(labels) } + + // Then + assertEquals( + mapOf( + SortOrder.FIRST_INSTALLED to "First installed", + SortOrder.LAST_UPDATED to "Last updated", + SortOrder.NAME to "Name", + SortOrder.PACKAGE_NAME to "Package name" + ), + result + ) + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt index a4cedc1d..806c21f5 100644 --- a/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt @@ -1,8 +1,16 @@ package de.psdev.devdrawer.widgets.ui.editor import de.psdev.devdrawer.MainDispatcherRule +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetHeaderColor import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.fakes.FakeAppsService +import de.psdev.devdrawer.fakes.FakePackageFilterRepository +import de.psdev.devdrawer.fakes.FakeSortOrderSettings import de.psdev.devdrawer.fakes.FakeWidgetProfileRepository import de.psdev.devdrawer.fakes.FakeWidgetRepository import io.mockk.mockk @@ -11,26 +19,39 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) class WidgetEditorViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private val profile = WidgetProfile(id = "profile-1", name = "My Profile") - private val widget = Widget(id = 1, name = "Test Widget", color = 0xFF0000, profileId = "profile-1") + private val other = WidgetProfile(id = "profile-2", name = "Other") + private val widget = Widget(id = 1, name = "Test Widget", color = 0, profileId = "profile-1") + private val zeta = PackageHashInfo("com.example.zeta", firstInstallTime = 1, lastUpdateTime = 30, signatureHashSha256 = "k") + private val alpha = PackageHashInfo("com.example.alpha", firstInstallTime = 2, lastUpdateTime = 10, signatureHashSha256 = "k") - private fun createViewModel( - widgets: List = listOf(widget), - profiles: List = listOf(profile) - ) = WidgetEditorViewModel( + private fun createViewModel(widgets: List = listOf(widget)) = WidgetEditorViewModel( widgetId = 1, widgetRepository = FakeWidgetRepository(widgets), - widgetProfileRepository = FakeWidgetProfileRepository(profiles), + widgetProfileRepository = FakeWidgetProfileRepository(listOf(profile, other)), + packageFilterRepository = FakePackageFilterRepository( + listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "profile-1")) + ), + appsService = FakeAppsService(mapOf(zeta to "Zeta", alpha to "Alpha")), + sortOrderSettings = FakeSortOrderSettings(SortOrder.LAST_UPDATED), trackingService = mockk(relaxed = true) ) @@ -44,7 +65,7 @@ class WidgetEditorViewModelTest { } @Test - fun `given a widget and profile exist, when the state is collected, then both are emitted`() = runTest { + fun `given a widget and profiles, when loaded, then profiles carry app counts and the preview shows matching apps`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } @@ -53,62 +74,79 @@ class WidgetEditorViewModelTest { advanceUntilIdle() // Then - assertEquals(widget, viewModel.state.value.persistedWidget) - assertEquals(widget, viewModel.state.value.editableWidget) - assertEquals(listOf(profile), viewModel.state.value.widgetProfiles) + val state = viewModel.state.value + assertEquals(widget, state.persistedWidget) + assertEquals(listOf("My Profile" to 2, "Other" to 0), state.profiles.map { it.profile.name to it.appCount }) + assertEquals(listOf("Zeta", "Alpha"), state.previewApps.map { it.name }) + assertEquals(SortOrder.LAST_UPDATED, state.defaultSortOrder) + assertFalse(state.isDirty) } @Test - fun `given a loaded widget, when the name is changed, then editable name updates without affecting persisted name`() = - runTest { - // Given - val viewModel = createViewModel() - backgroundScope.launch { viewModel.state.collect {} } - advanceUntilIdle() - - // When - viewModel.onNameChanged("Renamed") - advanceUntilIdle() - - // Then - assertEquals("Renamed", viewModel.state.value.editableWidget?.name) - assertEquals("Test Widget", viewModel.state.value.persistedWidget?.name) - } + fun `given a loaded widget, when the name is changed, then editable name updates without affecting persisted name`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + + // When + viewModel.onNameChanged("Renamed") + advanceUntilIdle() + + // Then + assertEquals("Renamed", viewModel.state.value.editableWidget?.name) + assertEquals("Test Widget", viewModel.state.value.persistedWidget?.name) + assertTrue(viewModel.state.value.isDirty) + } + + @Test + fun `given a loaded widget, when a header colour is picked, then only the editable widget changes`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + + // When + viewModel.onHeaderColorSelected(WidgetHeaderColor.DARK) + advanceUntilIdle() + + // Then + assertEquals(WidgetHeaderColor.DARK, viewModel.state.value.editableWidget?.headerColor) + assertEquals(WidgetHeaderColor.AMBER, viewModel.state.value.persistedWidget?.headerColor) + } @Test - fun `given a loaded widget, when the color is changed, then editable color updates without affecting persisted color`() = - runTest { - // Given - val viewModel = createViewModel() - backgroundScope.launch { viewModel.state.collect {} } - advanceUntilIdle() - - // When - viewModel.onWidgetColorChanged(0x0000FF) - advanceUntilIdle() - - // Then - assertEquals(0x0000FF, viewModel.state.value.editableWidget?.color) - assertEquals(0xFF0000, viewModel.state.value.persistedWidget?.color) - } + fun `given a loaded widget, when its own sort order is picked, then the preview follows it`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + + // When + viewModel.onSortOrderSelected(SortOrder.NAME) + advanceUntilIdle() + + // Then + assertEquals(SortOrder.NAME, viewModel.state.value.editableWidget?.sortOrder) + assertEquals(listOf("Alpha", "Zeta"), viewModel.state.value.previewApps.map { it.name }) + } @Test - fun `given a loaded widget, when a new profile is selected, then editable profileId updates without affecting persisted profileId`() = - runTest { - // Given - val other = WidgetProfile(id = "profile-2", name = "Other") - val viewModel = createViewModel(profiles = listOf(profile, other)) - backgroundScope.launch { viewModel.state.collect {} } - advanceUntilIdle() - - // When - viewModel.onWidgetProfileSelected(other) - advanceUntilIdle() - - // Then - assertEquals("profile-2", viewModel.state.value.editableWidget?.profileId) - assertEquals("profile-1", viewModel.state.value.persistedWidget?.profileId) - } + fun `given a loaded widget, when another profile is selected, then the preview and selection follow it`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + + // When + viewModel.onWidgetProfileSelected(other) + advanceUntilIdle() + + // Then + assertEquals("profile-2", viewModel.state.value.editableWidget?.profileId) + assertEquals("profile-1", viewModel.state.value.persistedWidget?.profileId) + assertEquals(emptyList(), viewModel.state.value.previewApps.map { it.name }) + } @Test fun `given a renamed widget, when changes are saved, then the persisted widget reflects the new name`() = runTest { @@ -124,6 +162,7 @@ class WidgetEditorViewModelTest { // Then assertEquals("Saved Name", viewModel.state.value.persistedWidget?.name) + assertFalse(viewModel.state.value.isDirty) } @Test diff --git a/app/src/test/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModelTest.kt new file mode 100644 index 00000000..0f01bee5 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModelTest.kt @@ -0,0 +1,68 @@ +package de.psdev.devdrawer.widgets.ui.list + +import de.psdev.devdrawer.MainDispatcherRule +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.fakes.FakeAppsService +import de.psdev.devdrawer.fakes.FakePackageFilterRepository +import de.psdev.devdrawer.fakes.FakeWidgetProfileRepository +import de.psdev.devdrawer.fakes.FakeWidgetRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetListScreenViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `given widgets with profiles, when loaded, then each summary shows its profile and app count`() = runTest { + // Given + val work = WidgetProfile(id = "work", name = "Work apps") + val empty = WidgetProfile(id = "empty", name = "Default") + val viewModel = WidgetListScreenViewModel( + application = RuntimeEnvironment.getApplication(), + widgetRepository = FakeWidgetRepository( + listOf( + Widget(id = 1, name = "Work", color = 0, profileId = "work"), + Widget(id = 2, name = "Widget 2", color = 0, profileId = "empty") + ) + ), + widgetProfileRepository = FakeWidgetProfileRepository(listOf(work, empty)), + packageFilterRepository = FakePackageFilterRepository( + listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "work")) + ), + appsService = FakeAppsService( + mapOf( + PackageHashInfo("com.example.a", 1, 1, "k") to "A", + PackageHashInfo("com.example.b", 1, 1, "k") to "B", + PackageHashInfo("org.other", 1, 1, "k") to "Other" + ) + ) + ) + backgroundScope.launch { viewModel.state.collect {} } + + // When + advanceUntilIdle() + + // Then + val loaded = viewModel.state.value as WidgetListScreenState.Loaded + assertEquals(listOf("Work apps" to 2, "Default" to 0), loaded.widgets.map { it.profileName to it.appCount }) + } +} From 05cb1a2678c9ba5f057647cc19be836afa27eb9d Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:09:38 +0200 Subject: [PATCH 05/15] Make profile actions visible: tap opens, menu duplicates or deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today a tap edits a profile, a long press deletes it, and swiping does either, with nothing on screen showing how to delete. Now a tap opens the profile and each card's overflow menu offers Duplicate and Delete. Deleting is immediate with Undo in a snackbar (the profile's filters come back too); a profile that widgets still use keeps the in-use dialog. Cards show "1 filter · 7 apps" and which widgets use the profile. The delete confirmation dialog and its strings are gone, replaced by undo. Co-Authored-By: Claude Opus 5.5 --- .../devdrawer/profiles/DeleteDialogState.kt | 5 +- .../devdrawer/profiles/WidgetProfileCard.kt | 144 ++++++++++++------ .../devdrawer/profiles/WidgetProfileList.kt | 116 ++------------ .../profiles/WidgetProfilesViewModel.kt | 99 ++++++++++-- .../ui/list/WidgetProfilesListScreen.kt | 120 +++++++-------- app/src/main/res/values-de/strings.xml | 17 ++- app/src/main/res/values/strings.xml | 17 ++- .../devdrawer/DialogStringResourcesTest.kt | 12 -- .../profiles/WidgetProfilesViewModelTest.kt | 130 +++++++++------- 9 files changed, 361 insertions(+), 299 deletions(-) diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt b/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt index 064a7e27..f5c7edad 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/DeleteDialogState.kt @@ -5,12 +5,9 @@ import de.psdev.devdrawer.database.WidgetProfile sealed class DeleteDialogState { data object Hidden : DeleteDialogState() - data class Showing( - val widgetProfile: WidgetProfile - ) : DeleteDialogState() data class InUseError( val widgetProfile: WidgetProfile, val widgets: List ) : DeleteDialogState() -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt index 277a2305..01d14bc9 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileCard.kt @@ -1,67 +1,122 @@ package de.psdev.devdrawer.profiles -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +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.material.icons.Icons -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Widgets +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource 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.database.WidgetProfile import de.psdev.devdrawer.ui.theme.DevDrawerTheme import de.psdev.devdrawer.utils.DefaultPreviews -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -@OptIn(ExperimentalFoundationApi::class) +/** + * A profile in the Profiles list: its name, how many filters and apps it has, and the widgets using it. + * Tapping opens it; the overflow menu duplicates or deletes it. + */ @Composable fun WidgetProfileCard( - widgetProfile: WidgetProfile, + summary: ProfileSummary, onWidgetProfileClick: (WidgetProfile) -> Unit = {}, - onWidgetProfileLongClick: (WidgetProfile) -> Unit = {} + onDuplicateClick: (WidgetProfile) -> Unit = {}, + onDeleteClick: (WidgetProfile) -> Unit = {} ) { + val profile = summary.profile Card( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp) - .combinedClickable( - onClick = { onWidgetProfileClick(widgetProfile) }, - onLongClick = { onWidgetProfileLongClick(widgetProfile) } - ) + .padding(horizontal = 8.dp, vertical = 4.dp), + onClick = { onWidgetProfileClick(profile) } ) { - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(16.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyMedium, - text = widgetProfile.name - ) - Spacer(modifier = Modifier.size(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.Schedule, - contentDescription = stringResource(id = R.string.last_modified) - ) - Spacer(modifier = Modifier.size(4.dp)) + Row(modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(text = profile.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) Text( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - style = MaterialTheme.typography.bodySmall, - text = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .format(widgetProfile.updatedAt.atZone(ZoneId.systemDefault())) + text = if (summary.filterCount == 0) { + stringResource(R.string.profile_no_filters) + } else { + stringResource( + R.string.profile_counts, + pluralStringResource(R.plurals.profile_filter_count, summary.filterCount, summary.filterCount), + pluralStringResource(R.plurals.widget_app_count, summary.appCount, summary.appCount) + ) + }, + style = MaterialTheme.typography.bodyMedium, + color = if (summary.appCount == 0) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(Modifier.size(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (summary.usedBy.isNotEmpty()) { + Icon( + imageVector = Icons.Filled.Widgets, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.size(4.dp)) + } + Text( + text = if (summary.usedBy.isEmpty()) { + stringResource(R.string.profile_not_used) + } else { + stringResource(R.string.profile_used_by, summary.usedBy.joinToString()) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Box { + var menuOpen by remember { mutableStateOf(false) } + IconButton(onClick = { menuOpen = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.profile_more_options, profile.name) + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.duplicate)) }, + leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) }, + onClick = { + menuOpen = false + onDuplicateClick(profile) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.delete)) }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onDeleteClick(profile) + } + ) + } } } } @@ -71,6 +126,9 @@ fun WidgetProfileCard( @Composable fun Preview_WidgetProfileCard() { DevDrawerTheme { - WidgetProfileCard(widgetProfile = WidgetProfile(name = "Test profile")) + Column { + WidgetProfileCard(ProfileSummary(WidgetProfile(name = "Signed like DevDrawer2"), 1, 7, listOf("Work apps"))) + WidgetProfileCard(ProfileSummary(WidgetProfile(name = "Default"), 0, 0, emptyList())) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt index 12eb7d2a..c450ad5d 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfileList.kt @@ -1,126 +1,38 @@ package de.psdev.devdrawer.profiles import android.content.res.Configuration -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize 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.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue -import androidx.compose.material3.rememberSwipeToDismissBoxState 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.draw.scale -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.ui.theme.DevDrawerTheme -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WidgetProfileList( - widgetProfiles: List, + profiles: List, modifier: Modifier = Modifier, onWidgetProfileClick: (WidgetProfile) -> Unit = {}, - onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, - onEditProfile: (WidgetProfile) -> Unit = {}, + onDuplicateProfile: (WidgetProfile) -> Unit = {}, onDeleteProfile: (WidgetProfile) -> Unit = {} ) { LazyColumn( modifier = modifier .fillMaxWidth() - .fillMaxHeight() + .fillMaxHeight(), + contentPadding = PaddingValues(top = 4.dp, bottom = 88.dp) ) { - items(widgetProfiles, key = { it.id }) { widgetProfile -> - val dismissState = rememberSwipeToDismissBoxState() - - LaunchedEffect(dismissState.currentValue) { - when (dismissState.currentValue) { - SwipeToDismissBoxValue.StartToEnd -> { - onEditProfile(widgetProfile) - dismissState.snapTo(SwipeToDismissBoxValue.Settled) - } - - SwipeToDismissBoxValue.EndToStart -> { - onDeleteProfile(widgetProfile) - dismissState.snapTo(SwipeToDismissBoxValue.Settled) - } - - else -> {} - } - } - - SwipeToDismissBox( - state = dismissState, - backgroundContent = { - val direction = dismissState.dismissDirection - val color by animateColorAsState( - when (direction) { - SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primaryContainer - SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer - else -> Color.Transparent - }, label = "dismiss_color" - ) - val alignment = when (direction) { - SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart - SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd - else -> Alignment.Center - } - val icon = when (direction) { - SwipeToDismissBoxValue.StartToEnd -> Icons.Default.Edit - SwipeToDismissBoxValue.EndToStart -> Icons.Default.Delete - else -> null - } - val scale by animateFloatAsState( - if (dismissState.targetValue == SwipeToDismissBoxValue.Settled) 0.75f else 1f, - label = "dismiss_scale" - ) - - Box( - Modifier - .fillMaxSize() - .padding(8.dp) - .background(color, MaterialTheme.shapes.medium) - .padding(horizontal = 20.dp), - contentAlignment = alignment - ) { - if (icon != null) { - Icon( - icon, - contentDescription = null, - modifier = Modifier.scale(scale), - tint = when (direction) { - SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.onPrimaryContainer - SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.onErrorContainer - else -> MaterialTheme.colorScheme.onSurface - } - ) - } - } - }, - content = { - WidgetProfileCard( - widgetProfile = widgetProfile, - onWidgetProfileClick = onWidgetProfileClick, - onWidgetProfileLongClick = onWidgetProfileLongClick - ) - } + items(profiles, key = { it.profile.id }) { summary -> + WidgetProfileCard( + summary = summary, + onWidgetProfileClick = onWidgetProfileClick, + onDuplicateClick = onDuplicateProfile, + onDeleteClick = onDeleteProfile ) } } @@ -133,9 +45,9 @@ fun Preview_WidgetProfileList() { DevDrawerTheme { WidgetProfileList( listOf( - WidgetProfile(name = "Profile 1"), - WidgetProfile(name = "Profile 2") + ProfileSummary(WidgetProfile(name = "Profile 1"), 1, 7, listOf("Work apps")), + ProfileSummary(WidgetProfile(name = "Profile 2"), 0, 0, emptyList()) ) ) } -} \ No newline at end of file +} 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 66011573..e66bdb54 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModel.kt @@ -1,47 +1,117 @@ package de.psdev.devdrawer.profiles +import android.app.Application +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.R import de.psdev.devdrawer.analytics.Events import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.apps.matching +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.database.PackageFilter +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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map +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 +data class ProfileSummary( + val profile: WidgetProfile, + val filterCount: Int, + val appCount: Int, + /** Names of the widgets that use this profile. */ + val usedBy: List +) + +sealed class DeleteResult { + /** Deleted; holds what [WidgetProfilesViewModel.undoDelete] needs to bring it back. */ + data class Deleted(val profile: WidgetProfile, val filters: List) : DeleteResult() + + /** Not deleted, because these widgets still use it. */ + data class InUse(val profile: WidgetProfile, val widgets: List) : DeleteResult() +} + @HiltViewModel class WidgetProfilesViewModel @Inject constructor( + private val application: Application, private val widgetProfileRepository: IWidgetProfileRepository, private val widgetRepository: IWidgetRepository, + private val packageFilterRepository: IPackageFilterRepository, + private val appsService: IAppsService, private val trackingService: TrackingService ) : ViewModel() { - val viewState = widgetProfileRepository.widgetProfilesFlow() - .map { UiState.Success(it) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) + private val installedPackages = MutableStateFlow>(emptyList()) - fun prepareProfileDeletion(widgetProfile: WidgetProfile, onResult: (DeleteDialogState) -> Unit) { + init { viewModelScope.launch { - val widgets = widgetRepository.findWidgetsForProfile(widgetProfile.id) - onResult( - if (widgets.isNotEmpty()) { - DeleteDialogState.InUseError(widgetProfile = widgetProfile, widgets = widgets) - } else { - DeleteDialogState.Showing(widgetProfile) - } - ) + installedPackages.value = appsService.installedPackages(includeSystemApps = true) } } - fun deleteProfile(widgetProfile: WidgetProfile) { + val viewState = combine( + widgetProfileRepository.widgetProfilesFlow(), + packageFilterRepository.allFiltersFlow(), + widgetRepository.widgetsFlow(), + installedPackages + ) { profiles, filters, widgets, packages -> + val filtersByProfile = filters.groupBy { it.profileId } + val widgetsByProfile = widgets.groupBy { it.profileId } + UiState.Success( + profiles.map { profile -> + val profileFilters = filtersByProfile[profile.id].orEmpty() + ProfileSummary( + profile = profile, + filterCount = profileFilters.size, + appCount = packages.matching(profileFilters).size, + usedBy = widgetsByProfile[profile.id].orEmpty().map { it.name } + ) + } + ) + }.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) { viewModelScope.launch { + val widgets = widgetRepository.findWidgetsForProfile(widgetProfile.id) + if (widgets.isNotEmpty()) { + onResult(DeleteResult.InUse(widgetProfile, widgets)) + return@launch + } + // Deleting the profile cascades to its filters; keep them so undo can restore both. + val filters = packageFilterRepository.findAllByProfile(widgetProfile.id) widgetProfileRepository.delete(widgetProfile) trackingService.trackAction(Events.PROFILE_DELETED) + onResult(DeleteResult.Deleted(widgetProfile, filters)) + } + } + + fun undoDelete(deleted: DeleteResult.Deleted) { + viewModelScope.launch { + widgetProfileRepository.create(deleted.profile) + deleted.filters.forEach { packageFilterRepository.save(it) } + } + } + + fun duplicateProfile(widgetProfile: WidgetProfile) { + viewModelScope.launch { + val copy = WidgetProfile(name = application.getString(R.string.profile_copy_name, widgetProfile.name)) + widgetProfileRepository.create(copy) + packageFilterRepository.findAllByProfile(widgetProfile.id).forEach { filter -> + packageFilterRepository.save(filter.copy(id = UUID.randomUUID().toString(), profileId = copy.id)) + } + trackingService.trackAction(Events.PROFILE_CREATED) } } @@ -54,5 +124,4 @@ class WidgetProfilesViewModel @Inject constructor( onCreated(widgetProfile) } } - } 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 5451b840..efe4fa39 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 @@ -10,21 +10,25 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -32,13 +36,15 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import de.psdev.devdrawer.R import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.profiles.DeleteDialogState +import de.psdev.devdrawer.profiles.DeleteResult +import de.psdev.devdrawer.profiles.ProfileSummary import de.psdev.devdrawer.profiles.WidgetInUseErrorAlertDialog import de.psdev.devdrawer.profiles.WidgetProfileList import de.psdev.devdrawer.profiles.WidgetProfilesViewModel import de.psdev.devdrawer.ui.UiState import de.psdev.devdrawer.ui.loading.LoadingView import de.psdev.devdrawer.ui.theme.DevDrawerTheme -import java.util.UUID +import kotlinx.coroutines.launch @Composable fun WidgetProfilesScreen( @@ -47,36 +53,46 @@ fun WidgetProfilesScreen( ) { var deleteDialogShown by remember { mutableStateOf(DeleteDialogState.Hidden) } val viewState by viewModel.viewState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current - val onDeleteProfile: (WidgetProfile) -> Unit = { widgetProfile -> - viewModel.prepareProfileDeletion(widgetProfile) { state -> - deleteDialogShown = state - } + Box(modifier = Modifier.fillMaxSize()) { + WidgetProfileListScreen( + viewState = viewState, + onWidgetProfileClick = onEditProfile, + onDuplicateProfile = viewModel::duplicateProfile, + onDeleteProfile = { profile -> + viewModel.deleteProfile(profile) { result -> + when (result) { + is DeleteResult.InUse -> deleteDialogShown = DeleteDialogState.InUseError(result.profile, result.widgets) + is DeleteResult.Deleted -> scope.launch { + val action = snackbarHostState.showSnackbar( + message = context.getString(R.string.profile_deleted, result.profile.name), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long + ) + if (action == SnackbarResult.ActionPerformed) viewModel.undoDelete(result) + } + } + } + }, + onCreateWidgetProfileClick = { + viewModel.createNewProfile { widgetProfile -> + onEditProfile(widgetProfile) + } + } + ) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 80.dp) + ) } - WidgetProfileListScreen( - viewState = viewState, - onWidgetProfileClick = onEditProfile, - onWidgetProfileLongClick = onDeleteProfile, - onEditProfile = onEditProfile, - onDeleteProfile = onDeleteProfile, - onCreateWidgetProfileClick = { - viewModel.createNewProfile { widgetProfile -> - onEditProfile(widgetProfile) - } - } - ) when (val state = deleteDialogShown) { DeleteDialogState.Hidden -> Unit - is DeleteDialogState.Showing -> DeleteProfileDialog( - widgetProfile = state.widgetProfile, - onConfirm = { - viewModel.deleteProfile(state.widgetProfile) - deleteDialogShown = DeleteDialogState.Hidden - }, - onDismiss = { deleteDialogShown = DeleteDialogState.Hidden } - ) - is DeleteDialogState.InUseError -> { WidgetInUseErrorAlertDialog(state, onDismiss = { deleteDialogShown = DeleteDialogState.Hidden @@ -85,39 +101,11 @@ fun WidgetProfilesScreen( } } -@Composable -internal fun DeleteProfileDialog( - widgetProfile: WidgetProfile, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = stringResource(id = R.string.delete_profile)) - }, - text = { - Text(text = stringResource(id = R.string.delete_profile_confirmation, widgetProfile.name)) - }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(id = R.string.delete)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(id = R.string.cancel)) - } - } - ) -} - @Composable fun WidgetProfileListScreen( - viewState: UiState>, + viewState: UiState>, onWidgetProfileClick: (WidgetProfile) -> Unit = {}, - onWidgetProfileLongClick: (WidgetProfile) -> Unit = {}, - onEditProfile: (WidgetProfile) -> Unit = {}, + onDuplicateProfile: (WidgetProfile) -> Unit = {}, onDeleteProfile: (WidgetProfile) -> Unit = {}, onCreateWidgetProfileClick: () -> Unit = {} ) { @@ -139,7 +127,7 @@ fun WidgetProfileListScreen( Button(onClick = onCreateWidgetProfileClick) { Icon( imageVector = Icons.Outlined.Add, - contentDescription = stringResource(id = R.string.widget_profile_list_create_new) + contentDescription = null ) Text(text = stringResource(id = R.string.widget_profile_list_create_new)) } @@ -147,10 +135,9 @@ fun WidgetProfileListScreen( } else { Box(modifier = Modifier.fillMaxSize()) { WidgetProfileList( - widgetProfiles = profiles, + profiles = profiles, onWidgetProfileClick = onWidgetProfileClick, - onWidgetProfileLongClick = onWidgetProfileLongClick, - onEditProfile = onEditProfile, + onDuplicateProfile = onDuplicateProfile, onDeleteProfile = onDeleteProfile ) FloatingActionButton( @@ -168,7 +155,6 @@ fun WidgetProfileListScreen( } } } - } @Preview(showSystemUi = true) @@ -176,9 +162,7 @@ fun WidgetProfileListScreen( @Composable fun Preview_WidgetProfileListScreen_Empty() { DevDrawerTheme { - WidgetProfileListScreen( - viewState = UiState.Success(emptyList()) - ) + WidgetProfileListScreen(viewState = UiState.Success(emptyList())) } } @@ -190,8 +174,8 @@ fun Preview_WidgetProfileListScreen_Profiles() { WidgetProfileListScreen( viewState = UiState.Success( listOf( - WidgetProfile(UUID.randomUUID().toString(), "Profile 1"), - WidgetProfile(UUID.randomUUID().toString(), "Profile 2"), + ProfileSummary(WidgetProfile(name = "Signed like DevDrawer2"), 1, 7, listOf("Work apps")), + ProfileSummary(WidgetProfile(name = "Default"), 0, 0, emptyList()) ) ) ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 034d9b39..4aa7f629 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -54,7 +54,6 @@ Farben aus deinem Hintergrundbild verwenden Analyse zulassen Leeren - Profil löschen? Filter löschen? Keine Apps verfügbar oder alle bereits als Filter hinzugefügt Mehr @@ -82,7 +81,6 @@ Zustimmen Ablehnen Danke! Du kannst deine Entscheidung jederzeit im Einstellungen-Tab ändern. - Möchtest du das Profil \'%1$s\' wirklich löschen? Fehler: %1$s Das Profil %1$s wird verwendet von: \n%2$s Diese App hat keine Activities @@ -140,4 +138,19 @@ Sortieren nach Standard (%1$s) Profil %1$s bearbeiten + + + %1$s (Kopie) + Keine Filter · keine Apps + %1$s · %2$s + Verwendet von %1$s + Von keinem Widget verwendet + Weitere Optionen für %1$s + Duplizieren + Profil „%1$s“ gelöscht + Rückgängig + + %d Filter + %d Filter + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd89f4f6..af1c9f13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -103,7 +103,6 @@ Use colors generated from your wallpaper Opt-in to analytics Clear - Delete profile? Delete filter? No apps available or all already added as filter More @@ -122,7 +121,6 @@ Opt-in Opt-out Thank you! You can change your decision anytime on the settings tab. - Do you really want to delete the profile \'%1$s\'? Error: %1$s The profile %1$s is used by: \n%2$s This app has no activities @@ -180,4 +178,19 @@ Sort by Default (%1$s) Edit profile %1$s + + + %1$s (copy) + No filters · no apps + %1$s · %2$s + Used by %1$s + Not used by any widget + More options for %1$s + Duplicate + Profile “%1$s” deleted + Undo + + %d filter + %d filters + diff --git a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt index 0226c1fd..e24c20c8 100644 --- a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt @@ -15,7 +15,6 @@ import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialog import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialog import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel -import de.psdev.devdrawer.profiles.ui.list.DeleteProfileDialog import de.psdev.devdrawer.settings.ListPreference import de.psdev.devdrawer.ui.theme.DevDrawerTheme import org.junit.Rule @@ -49,17 +48,6 @@ class DialogStringResourcesTest { composeTestRule.onNodeWithText("Wir verwenden Firebase Analytics", substring = true).assertExists() } - @Test - fun `given a German locale, when showing the delete profile dialog, then it reads German`() { - // Given / When - composeTestRule.setContent { - DevDrawerTheme { DeleteProfileDialog(widgetProfile = widgetProfile, onConfirm = {}, onDismiss = {}) } - } - - // Then - assertTexts("Profil löschen?", "Möchtest du das Profil 'Arbeit' wirklich löschen?", "Löschen", "Abbrechen") - } - @Test fun `given a German locale, when showing the profile in use error, then it reads German`() { // Given diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModelTest.kt index bf7f08ca..c8965ef6 100644 --- a/app/src/test/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/profiles/WidgetProfilesViewModelTest.kt @@ -1,8 +1,13 @@ package de.psdev.devdrawer.profiles import de.psdev.devdrawer.MainDispatcherRule +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.fakes.FakeAppsService +import de.psdev.devdrawer.fakes.FakePackageFilterRepository import de.psdev.devdrawer.fakes.FakeWidgetProfileRepository import de.psdev.devdrawer.fakes.FakeWidgetRepository import de.psdev.devdrawer.ui.UiState @@ -12,12 +17,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) class WidgetProfilesViewModelTest { @get:Rule @@ -25,15 +38,33 @@ class WidgetProfilesViewModelTest { private val profile1 = WidgetProfile(id = "p1", name = "Profile 1") private val profile2 = WidgetProfile(id = "p2", name = "Profile 2") + private val filter1 = PackageFilter(id = "f1", type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "p1") + + private lateinit var profileRepository: FakeWidgetProfileRepository + private lateinit var filterRepository: FakePackageFilterRepository private fun createViewModel( profiles: List = listOf(profile1, profile2), widgets: List = emptyList() - ) = WidgetProfilesViewModel( - widgetProfileRepository = FakeWidgetProfileRepository(profiles), - widgetRepository = FakeWidgetRepository(widgets), - trackingService = mockk(relaxed = true) - ) + ): WidgetProfilesViewModel { + profileRepository = FakeWidgetProfileRepository(profiles) + filterRepository = FakePackageFilterRepository(listOf(filter1)) + return WidgetProfilesViewModel( + application = RuntimeEnvironment.getApplication(), + widgetProfileRepository = profileRepository, + widgetRepository = FakeWidgetRepository(widgets), + packageFilterRepository = filterRepository, + appsService = FakeAppsService( + mapOf( + PackageHashInfo("com.example.a", 1, 1, "k") to "A", + PackageHashInfo("com.example.b", 1, 1, "k") to "B" + ) + ), + trackingService = mockk(relaxed = true) + ) + } + + private fun WidgetProfilesViewModel.summaries() = (viewState.value as UiState.Success).data @Test fun `given a new view model, when no coroutines have run, then viewState is Loading`() { @@ -45,103 +76,100 @@ class WidgetProfilesViewModelTest { } @Test - fun `given profiles exist, when viewState is collected, then profiles are emitted`() = runTest { + fun `given profiles, filters and widgets, when loaded, then each summary has counts and its widgets`() = runTest { // Given - val viewModel = createViewModel() + val widget = Widget(id = 1, name = "Work apps", color = 0, profileId = "p1") + val viewModel = createViewModel(widgets = listOf(widget)) backgroundScope.launch { viewModel.viewState.collect {} } // When advanceUntilIdle() // Then - val state = viewModel.viewState.value - assertTrue(state is UiState.Success) - assertEquals(listOf(profile1, profile2), (state as UiState.Success).data) + val summaries = viewModel.summaries() + assertEquals(listOf(profile1, profile2), summaries.map { it.profile }) + assertEquals(listOf(1, 0), summaries.map { it.filterCount }) + assertEquals(listOf(2, 0), summaries.map { it.appCount }) + assertEquals(listOf(listOf("Work apps"), emptyList()), summaries.map { it.usedBy }) } @Test - fun `given two profiles, when one is deleted, then only the other remains in viewState`() = runTest { + fun `given an unused profile, when deleted, then it is gone and undo restores it with its filters`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.viewState.collect {} } advanceUntilIdle() // When - viewModel.deleteProfile(profile1) + var result: DeleteResult? = null + viewModel.deleteProfile(profile1) { result = it } + advanceUntilIdle() + + // Then + assertTrue(result is DeleteResult.Deleted) + assertEquals(listOf(profile2), viewModel.summaries().map { it.profile }) + + // When + viewModel.undoDelete((result as DeleteResult.Deleted)) advanceUntilIdle() // Then - val state = viewModel.viewState.value as UiState.Success - assertEquals(listOf(profile2), state.data) + assertEquals(setOf(profile1, profile2), viewModel.summaries().map { it.profile }.toSet()) + assertEquals(listOf(filter1), filterRepository.findAllByProfile("p1")) } @Test - fun `given no profiles, when a new profile is created, then it is added and the callback is invoked`() = runTest { + fun `given a profile in use by widgets, when deleting, then it stays and the widgets are reported`() = runTest { // Given - val viewModel = createViewModel(profiles = emptyList()) + val widget = Widget(id = 1, name = "Home Widget", color = 0, profileId = "p1") + val viewModel = createViewModel(widgets = listOf(widget)) backgroundScope.launch { viewModel.viewState.collect {} } advanceUntilIdle() // When - var created: WidgetProfile? = null - viewModel.createNewProfile { created = it } + var result: DeleteResult? = null + viewModel.deleteProfile(profile1) { result = it } advanceUntilIdle() // Then - assertNotNull(created) - assertEquals("Profile 1", created?.name) - assertEquals(1, (viewModel.viewState.value as UiState.Success).data.size) + assertEquals(DeleteResult.InUse(profile1, listOf(widget)), result) + assertEquals(listOf(profile1, profile2), viewModel.summaries().map { it.profile }) } @Test - fun `given two existing profiles, when a new profile is created, then it is named Profile 3`() = runTest { + fun `given a profile with filters, when duplicated, then a copy with copied filters is added`() = runTest { // Given - val viewModel = createViewModel(profiles = listOf(profile1, profile2)) + val viewModel = createViewModel() backgroundScope.launch { viewModel.viewState.collect {} } advanceUntilIdle() // When - var created: WidgetProfile? = null - viewModel.createNewProfile { created = it } + viewModel.duplicateProfile(profile1) advanceUntilIdle() // Then - assertEquals("Profile 3", created?.name) + val copy = profileRepository.findAll().single { it.id !in setOf("p1", "p2") } + assertEquals("Profile 1 (copy)", copy.name) + val copiedFilter = filterRepository.findAllByProfile(copy.id).single() + assertEquals("com.example.*", copiedFilter.filter) + assertNotEquals(filter1.id, copiedFilter.id) } @Test - fun `given a profile with no widgets, when deletion is prepared, then Showing state is returned`() = runTest { + fun `given two existing profiles, when a new profile is created, then it is named Profile 3 and reported`() = runTest { // Given val viewModel = createViewModel() + backgroundScope.launch { viewModel.viewState.collect {} } advanceUntilIdle() // When - var result: DeleteDialogState? = null - viewModel.prepareProfileDeletion(profile1) { result = it } + var created: WidgetProfile? = null + viewModel.createNewProfile { created = it } advanceUntilIdle() // Then - assertTrue(result is DeleteDialogState.Showing) - assertEquals(profile1, (result as DeleteDialogState.Showing).widgetProfile) + assertNotNull(created) + assertEquals("Profile 3", created?.name) + assertEquals(3, viewModel.summaries().size) } - - @Test - fun `given a profile that is in use by widgets, when deletion is prepared, then InUseError state is returned`() = - runTest { - // Given - val widget = Widget(id = 1, name = "Home Widget", color = 0, profileId = "p1") - val viewModel = createViewModel(widgets = listOf(widget)) - advanceUntilIdle() - - // When - var result: DeleteDialogState? = null - viewModel.prepareProfileDeletion(profile1) { result = it } - advanceUntilIdle() - - // Then - assertTrue(result is DeleteDialogState.InUseError) - val error = result as DeleteDialogState.InUseError - assertEquals(profile1, error.widgetProfile) - assertEquals(listOf(widget), error.widgets) - } } From 2d2fb2166ce1162e4e15c37629b1a6e922a934d3 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:13:34 +0200 Subject: [PATCH 06/15] Show a profile's result while editing it The profile editor lists filters in plain language ("Signed like DevDrawer2", "Package matches com.example.*") with each filter's own app count, and a live "Matching apps (N)" list of what the profile shows, updated as filters are added or removed before saving. One "Add filter" button opens a sheet that explains both filter types in a sentence each. Tapping a filter shows its details; the close icon removes it (nothing is saved until Save, so no confirmation). The per-filter preview dialog and the delete-filter confirmation are gone, with their now-unused strings. Co-Authored-By: Claude Opus 5.5 --- .../ui/editor/DeletePackageFilterDialog.kt | 32 -- .../ui/editor/PackageFilterPreviewDialog.kt | 117 ----- .../PackageFilterPreviewDialogViewModel.kt | 31 -- .../profiles/ui/editor/WidgetProfileEditor.kt | 416 +++++++++--------- .../ui/editor/WidgetProfileEditorViewModel.kt | 27 +- .../ui/editor/WidgetProfileEditorViewState.kt | 7 +- app/src/main/res/values-de/strings.xml | 16 +- app/src/main/res/values/strings.xml | 16 +- .../devdrawer/DialogStringResourcesTest.kt | 7 +- .../psdev/devdrawer/GermanTranslationTest.kt | 6 +- .../ui/editor/DialogButtonLabelTest.kt | 26 -- .../profiles/ui/editor/FilterTypeLabelTest.kt | 16 +- .../WidgetProfileEditorViewModelTest.kt | 113 +++++ 13 files changed, 398 insertions(+), 432 deletions(-) delete mode 100644 app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt deleted file mode 100644 index 971a781b..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/DeletePackageFilterDialog.kt +++ /dev/null @@ -1,32 +0,0 @@ -package de.psdev.devdrawer.profiles.ui.editor - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import de.psdev.devdrawer.R - -@Composable -fun DeletePackageFilterDialog( - onDismiss: () -> Unit, - onConfirm: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = stringResource(id = R.string.delete_filter)) - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = stringResource(id = R.string.cancel)) - } - }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(text = stringResource(id = R.string.yes)) - } - } - ) - -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt deleted file mode 100644 index ddca646e..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialog.kt +++ /dev/null @@ -1,117 +0,0 @@ -package de.psdev.devdrawer.profiles.ui.editor - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -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.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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.unit.dp -import androidx.core.content.res.ResourcesCompat -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import de.psdev.devdrawer.R -import de.psdev.devdrawer.appwidget.AppInfo -import de.psdev.devdrawer.database.PackageFilter -import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.Error -import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.Loaded -import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel.ViewState.Loading -import de.psdev.devdrawer.ui.dialog.DefaultDialog -import de.psdev.devdrawer.ui.loading.LoadingView -import de.psdev.devdrawer.ui.theme.DevDrawerTheme - -@Composable -fun PackageFilterPreviewDialog( - packageFilter: PackageFilter, - viewModel: PackageFilterPreviewDialogViewModel = hiltViewModel(), - closeDialog: () -> Unit = {} -) { - val viewState by remember(viewModel) { viewModel.load(packageFilter) }.collectAsState(initial = Loading) - PackageFilterPreviewDialog( - viewState = viewState, closeDialog = closeDialog - ) -} - -@Composable -internal fun PackageFilterPreviewDialog( - viewState: PackageFilterPreviewDialogViewModel.ViewState, - closeDialog: () -> Unit = {} -) { - DefaultDialog( - onDismissRequest = closeDialog, - titleContent = { - 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.apps_matching_filter)) - } - }, - bottomContent = { - TextButton(modifier = Modifier.align(Alignment.End), onClick = closeDialog) { - Text(text = stringResource(id = R.string.close)) - } - } - ) { - when (viewState) { - Loading -> LoadingView( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp), - showText = false - ) - - is Loaded -> LazyColumn(modifier = Modifier.weight(1f, false)) { - items(viewState.data) { appInfo -> - AppInfoItem(appInfo = appInfo) - } - } - - 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_PackageFilterPreviewDialog() { - val context = LocalContext.current - val resources = context.resources - DevDrawerTheme { - Surface { - val baseAppInfo = AppInfo( - name = "Test app", - packageName = "Test package", - appIcon = ResourcesCompat.getDrawable(resources, R.drawable.ic_launcher_foreground, context.theme)!!, - firstInstallTime = System.currentTimeMillis(), - lastUpdateTime = System.currentTimeMillis(), - signatureHashSha256 = "1234" - ) - PackageFilterPreviewDialog( - viewState = Loaded( - listOf( - baseAppInfo, - baseAppInfo.copy(name = "App 2"), - ) - ) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt deleted file mode 100644 index 4fb690a1..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/PackageFilterPreviewDialogViewModel.kt +++ /dev/null @@ -1,31 +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.appwidget.AppInfo -import de.psdev.devdrawer.database.PackageFilter -import kotlinx.coroutines.flow.flow -import javax.inject.Inject - -@HiltViewModel -class PackageFilterPreviewDialogViewModel @Inject constructor( - private val appsService: AppsService -) : ViewModel() { - - fun load(packageFilter: PackageFilter) = flow { - try { - val appsForPackageFilter = appsService.getAppsForPackageFilter(packageFilter) - emit(ViewState.Loaded(appsForPackageFilter)) - } catch (e: Exception) { - emit(ViewState.Error(e.message.orEmpty())) - } - } - - 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/WidgetProfileEditor.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditor.kt index f0acb450..23835772 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 @@ -2,33 +2,31 @@ package de.psdev.devdrawer.profiles.ui.editor import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer 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.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Preview +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Save import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -38,22 +36,30 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import de.psdev.devdrawer.AppBarActionsProvider import de.psdev.devdrawer.ProvideMenu import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo import de.psdev.devdrawer.database.FilterType import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.ui.theme.DevDrawerTheme -import java.util.UUID + +private const val COLLAPSED_APP_COUNT = 5 @Composable fun WidgetProfileEditor( @@ -69,7 +75,6 @@ fun WidgetProfileEditor( ) { val viewState by viewModel.state.collectAsState(initial = WidgetProfileEditorViewState.Empty) var currentDialog by remember { mutableStateOf(WidgetProfileEditorDialogs.None) } - val hasChanges = viewState.isDirty BackHandler { @@ -91,28 +96,19 @@ fun WidgetProfileEditor( WidgetProfileEditor( viewState = viewState, modifier = modifier.fillMaxSize(), - onNameChange = { - viewModel.onNameChanged(it) - }, - onAddPackageFilterClick = { - currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters) - }, - onAddAppSignatureClick = { - currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters) - }, - onPackageFilterPreviewClick = { - currentDialog = WidgetProfileEditorDialogs.PackageFilterPreview(it) - }, - onPackageFilterInfoClick = { - currentDialog = WidgetProfileEditorDialogs.PackageFilterInfo(it) - }, - onDeletePackageFilterClick = { - currentDialog = WidgetProfileEditorDialogs.DeletePackageFilter(it) - } + onNameChange = viewModel::onNameChanged, + onAddFilterClick = { currentDialog = WidgetProfileEditorDialogs.AddFilter }, + onPackageFilterClick = { currentDialog = WidgetProfileEditorDialogs.PackageFilterInfo(it) }, + onRemoveFilterClick = viewModel::deleteFilter ) when (val dialog = currentDialog) { WidgetProfileEditorDialogs.None -> Unit + WidgetProfileEditorDialogs.AddFilter -> AddFilterSheet( + onDismiss = { currentDialog = WidgetProfileEditorDialogs.None }, + onSignatureClick = { currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters) }, + onPatternClick = { currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters) } + ) is WidgetProfileEditorDialogs.AddAppSignaturePackageFilter -> AddAppSignaturePackageFilterDialog( currentFilters = dialog.currentPackageFilters, closeDialog = { @@ -130,7 +126,6 @@ fun WidgetProfileEditor( currentDialog = WidgetProfileEditorDialogs.None } ) - is WidgetProfileEditorDialogs.AddPackageNamePackageFilter -> AddPackageNamePackageFilterDialog( currentFilters = dialog.currentPackageFilters, closeDialog = { @@ -147,30 +142,12 @@ fun WidgetProfileEditor( currentDialog = WidgetProfileEditorDialogs.None } ) - - is WidgetProfileEditorDialogs.PackageFilterPreview -> PackageFilterPreviewDialog( - packageFilter = dialog.packageFilter - ) { - currentDialog = WidgetProfileEditorDialogs.None - } - is WidgetProfileEditorDialogs.PackageFilterInfo -> PackageFilterInfoDialog( packageFilter = dialog.packageFilter, onDismiss = { currentDialog = WidgetProfileEditorDialogs.None } ) - - is WidgetProfileEditorDialogs.DeletePackageFilter -> DeletePackageFilterDialog( - onDismiss = { - currentDialog = WidgetProfileEditorDialogs.None - }, - onConfirm = { - viewModel.deleteFilter(dialog.packageFilter) - currentDialog = WidgetProfileEditorDialogs.None - } - ) - WidgetProfileEditorDialogs.DiscardChanges -> AlertDialog( onDismissRequest = { currentDialog = WidgetProfileEditorDialogs.None }, title = { Text(text = stringResource(R.string.discard_changes)) }, @@ -195,11 +172,7 @@ fun WidgetProfileEditor( private sealed class WidgetProfileEditorDialogs { data object None : WidgetProfileEditorDialogs() - - data class PackageFilterPreview( - val packageFilter: PackageFilter - ) : WidgetProfileEditorDialogs() - + data object AddFilter : WidgetProfileEditorDialogs() data class AddPackageNamePackageFilter( val currentPackageFilters: List ) : WidgetProfileEditorDialogs() @@ -212,24 +185,17 @@ private sealed class WidgetProfileEditorDialogs { val packageFilter: PackageFilter ) : WidgetProfileEditorDialogs() - data class DeletePackageFilter( - val packageFilter: PackageFilter - ) : WidgetProfileEditorDialogs() - data object DiscardChanges : WidgetProfileEditorDialogs() } -@OptIn(ExperimentalAnimationApi::class) @Composable internal fun WidgetProfileEditor( viewState: WidgetProfileEditorViewState, modifier: Modifier = Modifier, onNameChange: (String) -> Unit = {}, - onAddPackageFilterClick: (WidgetProfile) -> Unit = {}, - onAddAppSignatureClick: (WidgetProfile) -> Unit = {}, - onPackageFilterPreviewClick: (PackageFilter) -> Unit = {}, - onPackageFilterInfoClick: (PackageFilter) -> Unit = {}, - onDeletePackageFilterClick: (PackageFilter) -> Unit = {} + onAddFilterClick: () -> Unit = {}, + onPackageFilterClick: (PackageFilter) -> Unit = {}, + onRemoveFilterClick: (PackageFilter) -> Unit = {} ) { val widgetProfile = viewState.widgetProfile if (widgetProfile == null) { @@ -237,100 +203,57 @@ internal fun WidgetProfileEditor( Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.size(64.dp)) } - } else { - Box(modifier = modifier.fillMaxSize()) { - Column { - Surface(modifier = Modifier.wrapContentHeight(), shadowElevation = 2.dp) { - Column( - modifier = Modifier - .wrapContentHeight() - .padding(8.dp) - ) { - WidgetProfileName( - widgetName = viewState.widgetName ?: widgetProfile.name, - onNameChange = onNameChange - ) - Row( - modifier = Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - modifier = Modifier.weight(1f), - onClick = { onAddPackageFilterClick(widgetProfile) } - ) { - Icon( - modifier = Modifier.size(ButtonDefaults.IconSize), - painter = painterResource(id = R.drawable.ic_regex), - contentDescription = stringResource(id = R.string.add_package_name) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(id = R.string.add_package_name)) - } - Button( - modifier = Modifier.weight(1f), - onClick = { onAddAppSignatureClick(widgetProfile) } - ) { - Icon( - modifier = Modifier.size(ButtonDefaults.IconSize), - painter = painterResource(id = R.drawable.ic_certificate), - contentDescription = stringResource(id = R.string.add_app_signature) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(id = R.string.add_app_signature)) - } - } - } - } - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(viewState.packageFilters) { packageFilter -> - Card { - Row( - modifier = Modifier.padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val iconRes = when (packageFilter.type) { - FilterType.PACKAGE_NAME -> R.drawable.ic_regex - FilterType.SIGNATURE -> R.drawable.ic_certificate - } - Icon( - modifier = Modifier.padding(8.dp), - painter = painterResource(id = iconRes), - contentDescription = null - ) - val text = when (packageFilter.type) { - FilterType.PACKAGE_NAME -> packageFilter.filter - FilterType.SIGNATURE -> packageFilter.description - } - Text(modifier = Modifier.weight(1f), text = text) - AnimatedVisibility(visible = packageFilter.type == FilterType.SIGNATURE) { - Icon( - modifier = Modifier - .clickable { onPackageFilterInfoClick(packageFilter) } - .padding(8.dp), - imageVector = Icons.Filled.Info, - contentDescription = null - ) - } - Icon( - modifier = Modifier - .clickable { onPackageFilterPreviewClick(packageFilter) } - .padding(8.dp), - imageVector = Icons.Filled.Preview, - contentDescription = null - ) - Icon( - modifier = Modifier - .clickable { onDeletePackageFilterClick(packageFilter) } - .padding(8.dp), - imageVector = Icons.Filled.Delete, - contentDescription = null - ) - } - } + return + } + var showAllApps by rememberSaveable { mutableStateOf(false) } + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + value = viewState.widgetName ?: widgetProfile.name, + onValueChange = onNameChange, + label = { Text(text = stringResource(id = R.string.name)) } + ) + + Column { + SectionTitle(stringResource(R.string.filters_heading)) + if (viewState.packageFilters.isEmpty()) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(R.string.no_filters_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + viewState.packageFilters.forEachIndexed { index, packageFilter -> + if (index > 0) HorizontalDivider() + FilterRow( + packageFilter = packageFilter, + appCount = viewState.filterAppCounts[packageFilter.id] ?: 0, + onClick = { onPackageFilterClick(packageFilter) }, + onRemove = { onRemoveFilterClick(packageFilter) } + ) + } + FilledTonalButton(modifier = Modifier.padding(top = 8.dp), onClick = onAddFilterClick) { + Icon(imageVector = Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(modifier = Modifier.padding(start = 8.dp), text = stringResource(R.string.add_filter)) + } + } + + Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + SectionTitle(stringResource(R.string.matching_apps, viewState.matchingApps.size)) + val apps = if (showAllApps) viewState.matchingApps else viewState.matchingApps.take(COLLAPSED_APP_COUNT) + apps.forEach { app -> MatchingAppRow(app) } + if (!showAllApps && viewState.matchingApps.size > COLLAPSED_APP_COUNT) { + TextButton(onClick = { showAllApps = true }) { + Text(stringResource(R.string.show_all, viewState.matchingApps.size)) } } } @@ -338,72 +261,159 @@ internal fun WidgetProfileEditor( } } -@OptIn(ExperimentalAnimationApi::class) @Composable -fun WidgetProfileName( - widgetName: String, - onNameChange: (String) -> Unit = {} -) { +private fun SectionTitle(text: String) { + Text( + modifier = Modifier.padding(bottom = 4.dp), + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) +} + +/** A filter in plain words: "Signed like DevDrawer2" or "Package matches com.example.*". */ +@Composable +internal fun PackageFilter.label(): String = when (type) { + FilterType.SIGNATURE -> stringResource(R.string.profile_name_signed_like, description.ifBlank { filter.take(SHORT_HASH_LENGTH) }) + FilterType.PACKAGE_NAME -> stringResource(R.string.filter_package_matches, filter) +} + +private const val SHORT_HASH_LENGTH = 8 + +@Composable +private fun FilterRow(packageFilter: PackageFilter, appCount: Int, onClick: () -> Unit, onRemove: () -> Unit) { + val label = packageFilter.label() Row( - horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - modifier = Modifier.weight(1f), - singleLine = true, - value = widgetName, - onValueChange = onNameChange, - label = { Text(text = stringResource(id = R.string.name)) } + Icon( + painter = painterResource( + id = when (packageFilter.type) { + FilterType.PACKAGE_NAME -> R.drawable.ic_regex + FilterType.SIGNATURE -> R.drawable.ic_certificate + } + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + text = stringResource( + R.string.profile_counts, + stringResource( + when (packageFilter.type) { + FilterType.PACKAGE_NAME -> R.string.setup_source_pattern + FilterType.SIGNATURE -> R.string.filter_type_signature + } + ), + pluralStringResource(R.plurals.widget_app_count, appCount, appCount) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onRemove) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.remove_filter, label)) + } } } -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun Preview_WidgetProfileEditor_Loading() { - DevDrawerTheme { - WidgetProfileEditor( - viewState = WidgetProfileEditorViewState.Empty - ) +private fun MatchingAppRow(app: AppInfo) { + Row( + modifier = Modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val icon = remember(app.packageName) { app.appIcon.toBitmap().asImageBitmap() } + Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(32.dp)) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text(text = app.name, style = MaterialTheme.typography.bodyMedium) + Text( + text = app.packageName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +/** Both filter types side by side, each explained in one sentence, so the choice is by intent. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun Preview_WidgetProfileEditor_Loaded() { - val widgetProfile = WidgetProfile( - id = UUID.randomUUID().toString(), - name = "Test widget profile" - ) - DevDrawerTheme { - WidgetProfileEditor( - viewState = WidgetProfileEditorViewState( - widgetProfile = widgetProfile, - widgetName = widgetProfile.name, - packageFilters = listOf( - PackageFilter(profileId = widgetProfile.id, filter = "01022402020", type = FilterType.SIGNATURE), - PackageFilter(profileId = widgetProfile.id, filter = "com.example2.*") - ) +internal fun AddFilterSheet(onDismiss: () -> Unit, onSignatureClick: () -> Unit, onPatternClick: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(text = stringResource(R.string.add_filter), style = MaterialTheme.typography.titleLarge) + Text( + text = stringResource(R.string.add_filter_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - ) + FilterTypeOption( + iconRes = R.drawable.ic_certificate, + title = stringResource(R.string.add_filter_signature_title), + text = stringResource(R.string.add_filter_signature_text), + onClick = onSignatureClick + ) + FilterTypeOption( + iconRes = R.drawable.ic_regex, + title = stringResource(R.string.setup_source_pattern), + text = stringResource(R.string.add_filter_pattern_text), + onClick = onPatternClick + ) + } + } +} + +@Composable +private fun FilterTypeOption(iconRes: Int, title: String, text: String, onClick: () -> Unit) { + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium + ) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + Icon(painter = painterResource(iconRes), contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + Column(modifier = Modifier.padding(start = 16.dp)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text(text = text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } } } @Preview(showSystemUi = true) @Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun Preview_WidgetProfileEditor_NameChanged() { +fun Preview_WidgetProfileEditor_Loaded() { + val widgetProfile = WidgetProfile(id = "p1", name = "Test widget profile") DevDrawerTheme { WidgetProfileEditor( viewState = WidgetProfileEditorViewState( - widgetProfile = WidgetProfile( - id = UUID.randomUUID().toString(), - name = "Test widget profile" + widgetProfile = widgetProfile, + widgetName = widgetProfile.name, + packageFilters = listOf( + PackageFilter(id = "a", profileId = "p1", filter = "01022402020", type = FilterType.SIGNATURE, description = "DevDrawer2"), + PackageFilter(id = "b", profileId = "p1", filter = "com.example2.*") ), - widgetName = "Test widget profile 2", - isDirty = true + filterAppCounts = mapOf("a" to 2, "b" to 5) ) ) } 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 cb68e00b..7d52062c 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 @@ -6,6 +6,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import de.psdev.devdrawer.apps.IAppsService +import de.psdev.devdrawer.apps.comparator +import de.psdev.devdrawer.apps.matching +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.appwidget.SortOrder import de.psdev.devdrawer.database.DevDrawerDatabase import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.profiles.IPackageFilterRepository @@ -19,7 +24,8 @@ import kotlinx.coroutines.launch class WidgetProfileEditorViewModel @AssistedInject constructor( @Assisted private val profileId: String, private val database: DevDrawerDatabase, - private val packageFilterRepository: IPackageFilterRepository + private val packageFilterRepository: IPackageFilterRepository, + private val appsService: IAppsService ) : ViewModel() { @AssistedFactory @@ -33,12 +39,21 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( private val dbFiltersFlow = database.packageFilterDao().findAllByProfileFlow(profileId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + private val installedPackages = MutableStateFlow>(emptyList()) + + init { + viewModelScope.launch { + installedPackages.value = appsService.installedPackages(includeSystemApps = true) + } + } + val state = combine( database.widgetProfileDao().widgetProfileWithIdObservable(profileId), dbFiltersFlow, widgetNameState, - packageFiltersState - ) { widgetProfile, dbPackageFilters, name, inMemoryFilters -> + packageFiltersState, + installedPackages + ) { widgetProfile, dbPackageFilters, name, inMemoryFilters, packages -> val currentFilters = inMemoryFilters ?: dbPackageFilters val currentName = name ?: widgetProfile?.name.orEmpty() @@ -50,9 +65,11 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( widgetProfile = widgetProfile, widgetName = currentName, packageFilters = currentFilters, - isDirty = nameChanged || filtersChanged + isDirty = nameChanged || filtersChanged, + matchingApps = appsService.appInfos(packages.matching(currentFilters)).sortedWith(SortOrder.NAME.comparator()), + filterAppCounts = currentFilters.associate { it.id to packages.matching(listOf(it)).size } ) - } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetProfileEditorViewState.Empty) fun onNameChanged(name: String) { widgetNameState.value = name 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 195ab358..421bf415 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 @@ -1,6 +1,7 @@ package de.psdev.devdrawer.profiles.ui.editor import androidx.compose.runtime.Immutable +import de.psdev.devdrawer.appwidget.AppInfo import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile @@ -9,7 +10,11 @@ data class WidgetProfileEditorViewState( val widgetProfile: WidgetProfile? = null, val widgetName: String? = null, val packageFilters: List = emptyList(), - val isDirty: Boolean = false + val isDirty: Boolean = false, + /** 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() ) { companion object { val Empty = WidgetProfileEditorViewState() diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4aa7f629..53f8745e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -38,7 +38,6 @@ OK Profil kann nicht gelöscht werden, da es noch von Widgets verwendet wird Nein - Ja ComposeActivity Neues Profil erstellen Wird geladen… @@ -54,7 +53,6 @@ Farben aus deinem Hintergrundbild verwenden Analyse zulassen Leeren - Filter löschen? Keine Apps verfügbar oder alle bereits als Filter hinzugefügt Mehr System-Apps ein-/ausblenden @@ -153,4 +151,18 @@ %d Filter %d Filter + + + Apps anzeigen, die … + Noch keine Filter. Füge einen hinzu, um auszuwählen, welche Apps erscheinen. + Paket passt zu %1$s + Gleicher Signaturschlüssel + Filter %1$s entfernen + Filter hinzufügen + Eine App erscheint im Widget, wenn sie zu einem der Filter passt. + Signiert wie eine deiner Apps + Wähle eine installierte App. Jede App mit demselben Schlüssel passt, auch später installierte. + Passt auf Paketnamen, mit * als Platzhalter. Gut für ein Firmenpräfix wie com.mycompany.* + Passende Apps (%1$d) + Alle %1$d anzeigen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af1c9f13..b4c8f35c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,7 +73,6 @@ OK Cannot delete profile, still being used by widgets No - Yes ComposeActivity @@ -103,7 +102,6 @@ Use colors generated from your wallpaper Opt-in to analytics Clear - Delete filter? No apps available or all already added as filter More @@ -193,4 +191,18 @@ %d filter %d filters + + + Show apps that are… + No filters yet. Add one to choose which apps appear. + Package matches %1$s + Same signing key + Remove filter %1$s + Add filter + An app shows up in the widget if it matches any filter. + Signed like one of your apps + Pick an installed app. Every app signed with the same key matches, including ones you install later. + Match package names, with * as a wildcard. Good for a company prefix like com.mycompany.* + Matching apps (%1$d) + Show all %1$d diff --git a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt index e24c20c8..c04cdc70 100644 --- a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt @@ -13,8 +13,6 @@ import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialog import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialogViewModel import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialog import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel -import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialog -import de.psdev.devdrawer.profiles.ui.editor.PackageFilterPreviewDialogViewModel import de.psdev.devdrawer.settings.ListPreference import de.psdev.devdrawer.ui.theme.DevDrawerTheme import org.junit.Rule @@ -97,14 +95,11 @@ class DialogStringResourcesTest { AddPackageNamePackageFilterDialog( viewState = AddPackageNamePackageFilterDialogViewModel.ViewState.Error("defekt") ) - PackageFilterPreviewDialog( - viewState = PackageFilterPreviewDialogViewModel.ViewState.Error("weg") - ) } } // Then - assertTexts("Fehler: kaputt", "Fehler: defekt", "Fehler: weg") + assertTexts("Fehler: kaputt", "Fehler: defekt") } private fun assertTexts(vararg texts: String) { diff --git a/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt b/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt index f25fbb91..25ce2bf9 100644 --- a/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt @@ -25,11 +25,11 @@ class GermanTranslationTest { } @Test - fun `given a German locale, when reading yes, then it is translated`() { + fun `given a German locale, when reading discard, then it is translated`() { // Given / When - val result = context.getString(R.string.yes) + val result = context.getString(R.string.discard) // Then - assertEquals("Ja", result) + assertEquals("Verwerfen", result) } } diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt index 8a80edc2..41287f9f 100644 --- a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt @@ -29,17 +29,6 @@ class DialogButtonLabelTest { profileId = "profile" ) - @Test - fun `given the delete filter dialog, when shown, then its buttons are in sentence case`() { - // Given / When - composeTestRule.setContent { - DevDrawerTheme { DeletePackageFilterDialog(onDismiss = {}, onConfirm = {}) } - } - - // Then - assertSentenceCase("Cancel", "Yes") - } - @Test fun `given the add package name filter dialog, when loaded, then its buttons are in sentence case`() { // Given / When @@ -84,21 +73,6 @@ class DialogButtonLabelTest { assertSentenceCase("Close") } - @Test - fun `given the filter preview dialog, when loaded, then its button is in sentence case`() { - // Given / When - composeTestRule.setContent { - DevDrawerTheme { - PackageFilterPreviewDialog( - viewState = PackageFilterPreviewDialogViewModel.ViewState.Loaded(emptyList()) - ) - } - } - - // Then - assertSentenceCase("Close") - } - private fun assertSentenceCase(vararg labels: String) { labels.forEach { label -> composeTestRule.onNodeWithText(label).assertExists() diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/FilterTypeLabelTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/FilterTypeLabelTest.kt index 9d261044..49bd9265 100644 --- a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/FilterTypeLabelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/FilterTypeLabelTest.kt @@ -25,9 +25,13 @@ class FilterTypeLabelTest { val composeTestRule = createComposeRule() @Test - fun `given the profile editor, when loaded, then the add filter buttons are in sentence case`() { + fun `given the profile editor with filters, when loaded, then filters read as plain sentences`() { // Given - val widgetProfile = WidgetProfile(name = "Test profile") + val widgetProfile = WidgetProfile(id = "p1", name = "Test profile") + val filters = listOf( + PackageFilter(id = "a", type = FilterType.SIGNATURE, filter = "key", description = "DevDrawer2", profileId = "p1"), + PackageFilter(id = "b", type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "p1") + ) // When composeTestRule.setContent { @@ -35,14 +39,18 @@ class FilterTypeLabelTest { WidgetProfileEditor( viewState = WidgetProfileEditorViewState( widgetProfile = widgetProfile, - widgetName = widgetProfile.name + widgetName = widgetProfile.name, + packageFilters = filters, + filterAppCounts = mapOf("a" to 2, "b" to 1) ) ) } } // Then - assertSentenceCase("Package name", "App signature") + assertSentenceCase("Signed like DevDrawer2", "Package matches com.example.*", "Add filter") + composeTestRule.onNodeWithText("Same signing key · 2 apps").assertExists() + composeTestRule.onNodeWithText("Package name pattern · 1 app").assertExists() } @Test diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt new file mode 100644 index 00000000..c32783ea --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt @@ -0,0 +1,113 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import androidx.room.Room +import de.psdev.devdrawer.MainDispatcherRule +import de.psdev.devdrawer.appwidget.PackageHashInfo +import de.psdev.devdrawer.database.DevDrawerDatabase +import de.psdev.devdrawer.database.FilterType +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.database.WidgetProfile +import de.psdev.devdrawer.fakes.FakeAppsService +import de.psdev.devdrawer.fakes.FakePackageFilterRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetProfileEditorViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher()) + + private lateinit var database: DevDrawerDatabase + + private val profile = WidgetProfile(id = "p1", name = "Mine") + private val signatureFilter = PackageFilter(id = "f1", type = FilterType.SIGNATURE, filter = "key-a", description = "DevDrawer2", profileId = "p1") + + private val appsService = FakeAppsService( + mapOf( + PackageHashInfo("de.psdev.devdrawer", 1, 1, "key-a") to "DevDrawer2", + PackageHashInfo("com.yourcompany.other", 1, 1, "key-a") to "Other", + PackageHashInfo("com.example.client", 1, 1, "key-b") to "Client" + ) + ) + + @Before + fun setUp() = runTest { + database = Room.inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), DevDrawerDatabase::class.java) + .allowMainThreadQueries() + .build() + database.widgetProfileDao().insert(profile) + database.packageFilterDao().insert(signatureFilter) + } + + @After + fun tearDown() { + database.close() + } + + private fun createViewModel() = WidgetProfileEditorViewModel( + profileId = "p1", + database = database, + packageFilterRepository = FakePackageFilterRepository(), + appsService = appsService + ) + + @Test + fun `given a saved signature filter, when loaded, then matching apps and the filter's count are shown`() = runTest { + // Given + val viewModel = createViewModel() + + // When (Room emits on its own threads, so wait for the loaded state) + val state = viewModel.state.first { it.matchingApps.isNotEmpty() } + + // Then + assertEquals(listOf("DevDrawer2", "Other"), state.matchingApps.map { it.name }) + assertEquals(mapOf("f1" to 2), state.filterAppCounts) + } + + @Test + fun `given an added filter not yet saved, when the state updates, then matching apps include its apps`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + viewModel.state.first { it.matchingApps.isNotEmpty() } + + // When + viewModel.addPackageFilter(PackageFilter(id = "f2", type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "p1")) + val state = viewModel.state.first { it.matchingApps.size == 3 } + + // Then + assertEquals(listOf("Client", "DevDrawer2", "Other"), state.matchingApps.map { it.name }) + assertEquals(mapOf("f1" to 2, "f2" to 1), state.filterAppCounts) + } + + @Test + fun `given a removed filter not yet saved, when the state updates, then its apps disappear`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + viewModel.state.first { it.matchingApps.isNotEmpty() } + + // When + viewModel.deleteFilter(signatureFilter) + val state = viewModel.state.first { it.packageFilters.isEmpty() } + + // Then + assertEquals(emptyList(), state.matchingApps.map { it.name }) + } +} From f6f44233084513220ce03c57540b6a7bba1889d2 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:14:47 +0200 Subject: [PATCH 07/15] Say what each setting does in plain words - "Show activity choice on launch" becomes "Ask which screen to open", with a summary explaining when it applies. - "Widget Sorting Options" becomes "Default sort order": it now only applies to widgets without their own order. - The "User Interface" category becomes "Appearance" (sentence case). German follows. Co-Authored-By: Claude Opus 5.5 --- .../devdrawer/settings/SettingsScreen.kt | 1 + app/src/main/res/values-de/strings.xml | 7 +-- app/src/main/res/values/strings.xml | 7 +-- .../devdrawer/settings/SettingsScreenTest.kt | 48 +++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/de/psdev/devdrawer/settings/SettingsScreenTest.kt 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 ee25e7bd..064eef8d 100644 --- a/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/settings/SettingsScreen.kt @@ -76,6 +76,7 @@ fun SettingsScreen( PreferenceCategory(title = stringResource(id = R.string.settings_category_general)) SwitchPreference( text = stringResource(id = R.string.pref_show_activity_choice_title), + summary = stringResource(id = R.string.pref_show_activity_choice_summary), enabled = settings.activityChooserEnabled ) { onActivityChooserChanged(it) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 53f8745e..7c7ffcff 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -43,11 +43,12 @@ Wird geladen… Zuletzt geändert Allgemein - Benutzeroberfläche + Darstellung Analyse Über - Activity-Auswahl beim Start anzeigen - Widget-Sortierung + Nachfragen, welcher Bildschirm geöffnet wird + Wenn eine App mehrere Bildschirme hat, fragt ein Tipp im Widget, welcher gestartet werden soll + Standard-Sortierung Design Material-You-Farben Farben aus deinem Hintergrundbild verwenden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4c8f35c..7e810821 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -86,12 +86,13 @@ General - User Interface + Appearance Analytics About - Show activity choice on launch - Widget Sorting Options + Ask which screen to open + When an app has several screens, tapping it in the widget asks which one to start + Default sort order Theme System default diff --git a/app/src/test/java/de/psdev/devdrawer/settings/SettingsScreenTest.kt b/app/src/test/java/de/psdev/devdrawer/settings/SettingsScreenTest.kt new file mode 100644 index 00000000..b00b859a --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/settings/SettingsScreenTest.kt @@ -0,0 +1,48 @@ +package de.psdev.devdrawer.settings + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import de.psdev.devdrawer.appwidget.SortOrder +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class SettingsScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `given settings, when shown, then each preference says what it does in plain words`() { + // Given / When + composeTestRule.setContent { + DevDrawerTheme { + SettingsScreen( + viewState = SettingsViewModel.ViewState.Loaded( + analyticsVisible = false, + settings = SettingsViewModel.Settings( + activityChooserEnabled = false, + defaultSortOrder = SortOrder.LAST_UPDATED, + themeSetting = ThemeSetting.SYSTEM, + dynamicColorEnabled = false, + analyticsOptIn = false + ) + ) + ) + } + } + + // Then + composeTestRule.onNodeWithText("Ask which screen to open").assertExists() + composeTestRule.onNodeWithText("When an app has several screens, tapping it in the widget asks which one to start").assertExists() + composeTestRule.onNodeWithText("Default sort order").assertExists() + composeTestRule.onNodeWithText("Last updated").assertExists() + composeTestRule.onNodeWithText("Appearance").assertExists() + } +} From 19a47f23164d8e838608492d9cd71a75a6e150e7 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:17:48 +0200 Subject: [PATCH 08/15] Clean up after the redesign and update CLAUDE.md - Remove resources the redesign orphaned: the old widget-row icons and their tint colour, and eight strings no screen uses any more. - Read the Profiles snackbar's strings through LocalResources, which follows configuration changes (lint error). - "Show all" in setup reuses the profile editor's string instead of a string lint wants as a plural. - CLAUDE.md: Glance widget, setup route and configure result, shared app matching, database version 4, and the Robolectric-based UI, Glance and migration tests. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 45 ++++++++++++------- .../ui/list/WidgetProfilesListScreen.kt | 8 ++-- .../widgets/ui/setup/WidgetSetupScreen.kt | 2 +- .../drawable/ic_baseline_app_delete_24.xml | 10 ----- .../drawable/ic_baseline_app_settings_24.xml | 10 ----- app/src/main/res/values-de/strings.xml | 7 --- app/src/main/res/values-night/colors.xml | 1 - app/src/main/res/values/colors.xml | 1 - app/src/main/res/values/strings.xml | 7 --- 9 files changed, 35 insertions(+), 56 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_baseline_app_delete_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_app_settings_24.xml diff --git a/CLAUDE.md b/CLAUDE.md index 65e4a967..2bed59ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,9 @@ app filtering by package name/signature/regex, and dark mode. ``` Unit tests live in `app/src/test/` (JUnit 4, Robolectric, MockK, kotlinx-coroutines-test; repository fakes in -`fakes/`). There is no `src/androidTest/` source set and no instrumentation-test dependencies. +`fakes/`). Compose UI (`createComposeRule`), Glance (`runGlanceAppWidgetUnitTest`) and Room migration +(`MigrationTestHelper`) tests also run there under Robolectric; the schema exports are added to debug assets for them. +There is no `src/androidTest/` source set and no instrumentation-test dependencies. ## Architecture @@ -50,19 +52,25 @@ implementing `NavKey`, defined in `Routes.kt`. `DevDrawerHost.kt` maps each rout `remember { Navigator(navigationState) }` in `MainActivity`, passed down as a parameter) wraps the back stack mutations. The root composable `DevDrawerApp` (`DevDrawerApp.kt`) owns the `Scaffold`, top bar, and bottom nav bar with three top-level routes: `WidgetListRoute`, `WidgetProfilesRoute`, and `SettingsRoute`. `AboutRoute` and the detail routes -`WidgetEditorRoute(id)` / `WidgetProfileEditorRoute(id)` are also defined in `Routes.kt`. +`WidgetEditorRoute(id)` / `WidgetSetupRoute(widgetId)` / `WidgetProfileEditorRoute(id)` are also defined in `Routes.kt`. +`widgetLaunchFor()` (`WidgetLaunch.kt`) decides which route a widget intent opens; `MainActivity` sets the +configure result (`RESULT_OK` when setup finishes) when the launcher placed or reconfigures a widget. ### Data Layer -Room database (`DevDrawerDatabase`, version 3) with three entities and DAOs: +Room database (`DevDrawerDatabase`, version 4) with three entities and DAOs: | Entity | DAO | Purpose | |-----------------|--------------------|-----------------------------------------------------------| -| `Widget` | `WidgetDao` | Home screen widget instances | +| `Widget` | `WidgetDao` | Home screen widgets: name, profile, header colour, sort | | `WidgetProfile` | `WidgetProfileDao` | Named filter profiles | | `PackageFilter` | `PackageFilterDao` | Per-profile filter rules (package name, regex, signature) | -DB schema migrations live in `Migrations.kt`. Room schema JSON exports go to `/schemas/`. +DB schema migrations live in `Migrations.kt`. Room schema JSON exports go to `/schemas/`. `Widget.color` is the legacy +ARGB header colour, kept so the table needs no rebuild; `headerColor` (`WidgetHeaderColor`) replaces it. + +Which apps a profile shows is decided in one place: `matching()` and `SortOrder.comparator()` in `apps/AppMatching.kt`, +used by the widget, the Widgets list, both editors and the setup screen. `IAppsService` reads installed packages. ### Dependency Injection @@ -71,15 +79,20 @@ Hilt throughout. Key modules: - `ApplicationModule` — provides `SharedPreferences` - `DatabaseModule` — provides the Room DB and DAOs - `RepositoryModule` — binds the repository interfaces (`IWidgetRepository`, `IWidgetProfileRepository`, - `IPackageFilterRepository`) to their implementations + `IPackageFilterRepository`), `IAppsService` and `ISortOrderSettings` to their implementations +- `WidgetModule` — provides `WidgetContentLoader`; `WidgetEntryPoint` gives the Glance widget access to it ### Widget System -- `DDWidgetProvider` — `AppWidgetProvider` that renders `RemoteViews`; **never rename this class** as it breaks existing - placed widgets -- `WidgetService` + `WidgetAppsListViewFactory` — `RemoteViewsService` that populates the scrollable app list inside the - widget -- `ClickHandlingActivity` — trampoline activity for widget item taps +The home-screen widget is built with **Jetpack Glance**. + +- `DDWidgetProvider` — `GlanceAppWidgetReceiver` for `DevDrawerGlanceWidget`; **never rename this class** as it breaks + existing placed widgets +- `appwidget/glance/` — `DevDrawerGlanceWidget` loads the widget's content, `DevDrawerWidgetContent` draws it (header + palette from `ui/theme/WidgetHeaderPalette.kt`, empty state with "Choose apps"), `RefreshAction` reloads it +- `WidgetContentLoader` — a widget's matching apps in its sort order (its own, else the Settings default); app icons are + scaled to the 40 dp they are drawn at, because every icon travels in the widget's RemoteViews +- `ClickHandlingActivity` — trampoline activity for widget item taps (launch, uninstall, app details) - `UpdateWidgetsWorker` — `WorkManager` worker to refresh all widgets - `AppInstallationReceiver` — `BroadcastReceiver` for `PACKAGE_ADDED`/`PACKAGE_REMOVED` events that triggers widget refresh @@ -88,7 +101,8 @@ Hilt throughout. Key modules: ``` de.psdev.devdrawer/ -├── appwidget/ # Widget provider, service, click handler +├── apps/ # Installed apps and profile matching +├── appwidget/ # Glance widget, receiver, content loader, click handler ├── database/ # Room entities, DAOs, migrations ├── profiles/ # WidgetProfile feature (UI + repository) │ └── ui/ @@ -101,7 +115,8 @@ de.psdev.devdrawer/ └── widgets/ # Widget config feature (UI + repository) └── ui/ ├── editor/ # Widget editor screen & ViewModel - └── list/ # Widget list screen + ├── list/ # Widget list screen + └── setup/ # Setup when a widget is placed: which apps it shows ``` @@ -109,8 +124,8 @@ de.psdev.devdrawer/ - Dependency versions are managed in the version catalog `gradle/libs.versions.toml` (no dependency-updates plugin; Renovate is configured via `renovate.json`) -- `com.google.android.material:material` is required by the XML themes (`themes.xml`) and widget layouts, even though - all screens are Compose +- `com.google.android.material:material` is required by the XML themes (`themes.xml`), even though all screens are + Compose - Signing config is read from `release.properties` (local) or CI env vars (`keystore_password`, `keystore_alias`, `keystore_alias_password`) when `CI=true` 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 efe4fa39..fdc267fc 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 @@ -28,7 +28,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -55,7 +55,7 @@ fun WidgetProfilesScreen( val viewState by viewModel.viewState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val context = LocalContext.current + val resources = LocalResources.current Box(modifier = Modifier.fillMaxSize()) { WidgetProfileListScreen( @@ -68,8 +68,8 @@ fun WidgetProfilesScreen( is DeleteResult.InUse -> deleteDialogShown = DeleteDialogState.InUseError(result.profile, result.widgets) is DeleteResult.Deleted -> scope.launch { val action = snackbarHostState.showSnackbar( - message = context.getString(R.string.profile_deleted, result.profile.name), - actionLabel = context.getString(R.string.undo), + message = resources.getString(R.string.profile_deleted, result.profile.name), + actionLabel = resources.getString(R.string.undo), duration = SnackbarDuration.Long ) if (action == SnackbarResult.ActionPerformed) viewModel.undoDelete(result) diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt index a4f5139c..3d8a6ea6 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt @@ -112,7 +112,7 @@ fun WidgetSetup( if (!showAllApps && state.apps.size > COLLAPSED_APP_COUNT) { item { TextButton(onClick = { showAllApps = true }) { - Text(stringResource(R.string.setup_show_all_apps, state.apps.size)) + Text(stringResource(R.string.show_all, state.apps.size)) } } } diff --git a/app/src/main/res/drawable/ic_baseline_app_delete_24.xml b/app/src/main/res/drawable/ic_baseline_app_delete_24.xml deleted file mode 100644 index bc900d11..00000000 --- a/app/src/main/res/drawable/ic_baseline_app_delete_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_app_settings_24.xml b/app/src/main/res/drawable/ic_baseline_app_settings_24.xml deleted file mode 100644 index 836c788c..00000000 --- a/app/src/main/res/drawable/ic_baseline_app_settings_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7c7ffcff..4167ad6c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -10,7 +10,6 @@ Neu Name Profil bearbeiten - Anwenden Hinzufügen Neu laden Widget bearbeiten @@ -19,9 +18,6 @@ App Signatur Du kannst Profile bearbeiten, in dem du den Eintrag lange drückst Löschen - Deinstallieren - App Details - App Signatur Paketname App Icon DevDrawer @@ -31,7 +27,6 @@ Paketfilter Paketnamen-Filter eingeben Signatur aus App auswählen - Apps, die zum Filter passen Abbrechen Keine Widgets erstellt Widget hinzufügen @@ -41,7 +36,6 @@ ComposeActivity Neues Profil erstellen Wird geladen… - Zuletzt geändert Allgemein Darstellung Analyse @@ -109,7 +103,6 @@ Zum Beispiel com.mycompany.* Ein vorhandenes Profil Die Apps eines anderen Widgets verwenden - Alle %1$d Apps anzeigen Noch keine Profile Fertig Signiert wie %1$s diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 882b80ff..68533a9e 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -16,5 +16,4 @@ #FFFFFF - #FFF diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7b3fcf41..c8ec5edc 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -16,6 +16,5 @@ #FFFFFF - #000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e810821..556f1531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,7 +50,6 @@ New Name Edit profile - Apply Add Reload Edit widget @@ -59,14 +58,10 @@ App signature You can edit profiles by long-clicking an entry Delete - Uninstall - App details - App signature Package name App icon Enter package name filter Select signature from app - Apps matching filter Cancel No widgets created Add widget @@ -82,7 +77,6 @@ Loading… - Last modified General @@ -149,7 +143,6 @@ For example com.mycompany.* An existing profile Reuse the apps of another widget - Show all %1$d apps No profiles yet Done Signed like %1$s From 2f723c0151020f62e036b229644a6a173df25b95 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:25:12 +0200 Subject: [PATCH 09/15] Fix code review findings on the redesign - Widget: a running Glance session only recomposes on update(), so Reload and app-triggered updates could show stale apps. A refresh token in the widget's Glance state now makes the session reload; UpdateReceiver and RefreshAction bump it. - Widget: list at most 40 apps (with "+N more apps") and store icons at 72 px at most, keeping the RemoteViews under the Binder limit. - An invalid package pattern ("com.(") now matches nothing instead of throwing in every screen that matches apps. - Setup: Done can't create a second profile while saving. - Back from setup opened by the launcher or the widget closes the app (a widget being placed is then removed, as the launcher expects); in-app setup still goes back. - Editors only reload app labels and icons when filters, profile, sort order or installed apps change, not on every name keystroke; the Widgets list reads installed apps once. - The profile editor's matching apps reuse AppInfoItem. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/DevDrawerApp.kt | 6 +- .../java/de/psdev/devdrawer/DevDrawerHost.kt | 6 +- .../java/de/psdev/devdrawer/MainActivity.kt | 19 +++--- .../java/de/psdev/devdrawer/WidgetLaunch.kt | 9 +++ .../appwidget/glance/DevDrawerGlanceWidget.kt | 68 +++++++++++++++---- .../glance/DevDrawerWidgetContent.kt | 14 +++- .../appwidget/glance/RefreshAction.kt | 2 +- .../appwidget/glance/WidgetUiState.kt | 4 +- .../psdev/devdrawer/database/PackageFilter.kt | 5 +- .../profiles/ui/editor/AppInfoItem.kt | 5 +- .../profiles/ui/editor/WidgetProfileEditor.kt | 25 +------ .../ui/editor/WidgetProfileEditorViewModel.kt | 15 ++-- .../devdrawer/receivers/UpdateReceiver.kt | 28 ++++---- .../ui/editor/WidgetEditorViewModel.kt | 44 +++++++----- .../ui/list/WidgetListScreenViewModel.kt | 18 ++++- .../widgets/ui/setup/WidgetSetupState.kt | 6 +- .../widgets/ui/setup/WidgetSetupViewModel.kt | 5 +- app/src/main/res/values-de/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ .../de/psdev/devdrawer/WidgetLaunchTest.kt | 18 +++++ .../psdev/devdrawer/apps/AppMatchingTest.kt | 12 ++++ .../glance/DevDrawerWidgetContentTest.kt | 21 +++++- .../ui/setup/WidgetSetupViewModelTest.kt | 20 ++++++ 23 files changed, 257 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt index fbaca72a..811311a9 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt @@ -61,7 +61,8 @@ fun DevDrawerApp( navigationState: NavigationState, navigator: Navigator, trackingService: TrackingService, - onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() } + onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() }, + onWidgetSetupBack: (Int) -> Unit = { navigator.goBack() } ) { val settings by viewModel.persistedSettings.collectAsState() val darkTheme = when (settings.themeSetting) { @@ -154,7 +155,8 @@ fun DevDrawerApp( navigator = navigator, menuCallback = setMenu, modifier = Modifier.padding(innerPadding), - onWidgetSetupDone = onWidgetSetupDone + onWidgetSetupDone = onWidgetSetupDone, + onWidgetSetupBack = onWidgetSetupBack ) }, bottomBar = { diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt index 3fb907a6..9fa6c145 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt @@ -1,5 +1,6 @@ package de.psdev.devdrawer +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation3.runtime.entryProvider @@ -19,7 +20,8 @@ fun DevDrawerHost( navigator: Navigator, menuCallback: AppBarActionsProvider, modifier: Modifier = Modifier, - onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() } + onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() }, + onWidgetSetupBack: (Int) -> Unit = { navigator.goBack() } ) { val entryProvider = entryProvider { entry { @@ -60,6 +62,8 @@ fun DevDrawerHost( ) } entry { key -> + // Registered after the app bar's back handling, so it wins: setup from outside the app closes the app. + BackHandler { onWidgetSetupBack(key.widgetId) } WidgetSetupScreen( widgetId = key.widgetId, onDone = onWidgetSetupDone diff --git a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt index 2478cc7a..c66902cc 100644 --- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt @@ -13,7 +13,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint -import de.psdev.devdrawer.database.Widget import kotlinx.coroutines.launch import mu.KLogging @@ -75,7 +74,8 @@ class MainActivity : BaseActivity() { navigationState = navigationState, navigator = navigator, trackingService = trackingService, - onWidgetSetupDone = { widget -> onWidgetSetupDone(widget, navigator) } + onWidgetSetupDone = { widget -> leaveWidgetSetup(widget.id, navigator, done = true) }, + onWidgetSetupBack = { widgetId -> leaveWidgetSetup(widgetId, navigator, done = false) } ) } lifecycleScope.launch { @@ -113,12 +113,15 @@ class MainActivity : BaseActivity() { navigator.navigate(launch.route) } - private fun onWidgetSetupDone(widget: Widget, navigator: Navigator) { - if (widget.id == externalSetupWidgetId) { - if (isConfiguration) setConfigurationResult(RESULT_OK) - finish() - } else { - navigator.goBack() + /** Done keeps a widget the launcher is placing; back leaves the result cancelled, which removes it. */ + private fun leaveWidgetSetup(widgetId: Int, navigator: Navigator, done: Boolean) { + when (setupExitFor(widgetId, externalSetupWidgetId)) { + SetupExit.FINISH_ACTIVITY -> { + if (done && isConfiguration) setConfigurationResult(RESULT_OK) + externalSetupWidgetId = INVALID_APPWIDGET_ID + finish() + } + SetupExit.GO_BACK -> navigator.goBack() } } diff --git a/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt b/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt index 93ddf122..eeb8dfd4 100644 --- a/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt +++ b/app/src/main/java/de/psdev/devdrawer/WidgetLaunch.kt @@ -15,3 +15,12 @@ fun widgetLaunchFor(action: String?, widgetId: Int, openSetup: Boolean): WidgetL openSetup -> WidgetLaunch(WidgetSetupRoute(widgetId), isConfiguration = false) else -> WidgetLaunch(WidgetEditorRoute(widgetId), isConfiguration = false) } + +enum class SetupExit { FINISH_ACTIVITY, GO_BACK } + +/** + * How leaving setup (done or back) continues: setup opened from outside the app (launcher or widget) closes the + * activity and returns to the home screen; setup opened inside the app goes back. + */ +fun setupExitFor(widgetId: Int, externalSetupWidgetId: Int): SetupExit = + if (widgetId == externalSetupWidgetId) SetupExit.FINISH_ACTIVITY else SetupExit.GO_BACK 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 99605b75..070ae227 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 @@ -4,13 +4,22 @@ import android.content.Context import android.graphics.Bitmap import android.os.Build import android.text.format.DateFormat +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.core.graphics.drawable.toBitmap +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey import androidx.glance.GlanceId import androidx.glance.GlanceTheme import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.currentState import androidx.glance.material3.ColorProviders +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition import dagger.hilt.android.EntryPointAccessors import de.psdev.devdrawer.R import de.psdev.devdrawer.appwidget.AppInfo @@ -20,17 +29,26 @@ import de.psdev.devdrawer.database.WidgetHeaderColor import de.psdev.devdrawer.ui.theme.DarkColorScheme import de.psdev.devdrawer.ui.theme.LightColorScheme import java.util.Date +import kotlin.math.min /** The home-screen widget: a header with the widget's name, then the apps its profile matches. */ class DevDrawerGlanceWidget : GlanceAppWidget() { + override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + override suspend fun provideGlance(context: Context, id: GlanceId) { val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) val loader = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java).widgetContentLoader() - val state = loader.load(appWidgetId).toUiState(context, appWidgetId) - val useDynamicColors = state.headerColor == WidgetHeaderColor.DYNAMIC && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val load = suspend { loader.load(appWidgetId).toUiState(context, appWidgetId) } + val startToken = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[RefreshTokenKey] + val initialState = load() provideContent { - if (useDynamicColors) { + // While this session runs, update() only recomposes; a new refresh token is what reloads the apps. + val token = currentState()[RefreshTokenKey] + val state by produceState(initialState, token) { + if (token != startToken) value = load() + } + if (state.headerColor == WidgetHeaderColor.DYNAMIC && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { GlanceTheme { DevDrawerWidgetContent(state) } } else { GlanceTheme(colors = AppColors) { DevDrawerWidgetContent(state) } @@ -38,20 +56,41 @@ class DevDrawerGlanceWidget : GlanceAppWidget() { } } - private companion object { - val AppColors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme) + companion object { + private val AppColors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme) + private val RefreshTokenKey = longPreferencesKey("refresh_token") + + /** + * Every row's icon travels in the widget's RemoteViews, which must stay under the ~1 MB Binder limit: + * the list is capped and icons are drawn at 40 dp but never stored larger than 72 px. + */ + const val MAX_LISTED_APPS = 40 + private const val ICON_SIZE_DP = 40 + private const val MAX_ICON_SIZE_PX = 72 + + /** Reloads one widget's apps, also when its Glance session is still running. */ + suspend fun refresh(context: Context, glanceId: GlanceId) { + updateAppWidgetState(context, glanceId) { it[RefreshTokenKey] = System.nanoTime() } + DevDrawerGlanceWidget().update(context, glanceId) + } - const val ICON_SIZE_DP = 40 + suspend fun refreshAll(context: Context) { + GlanceAppWidgetManager(context).getGlanceIds(DevDrawerGlanceWidget::class.java).forEach { refresh(context, it) } + } } /** A widget whose setup isn't finished yet shows its empty state under the app's name. */ - private fun WidgetContent?.toUiState(context: Context, appWidgetId: Int): WidgetUiState = WidgetUiState( - appWidgetId = appWidgetId, - title = this?.widget?.name ?: context.getString(R.string.app_name), - headerColor = this?.widget?.headerColor ?: WidgetHeaderColor.AMBER, - updatedAt = DateFormat.getTimeFormat(context).format(Date()), - apps = this?.apps.orEmpty().map { it.toItem(context) } - ) + private fun WidgetContent?.toUiState(context: Context, appWidgetId: Int): WidgetUiState { + val apps = this?.apps.orEmpty() + return WidgetUiState( + appWidgetId = appWidgetId, + title = this?.widget?.name ?: context.getString(R.string.app_name), + headerColor = this?.widget?.headerColor ?: WidgetHeaderColor.AMBER, + updatedAt = DateFormat.getTimeFormat(context).format(Date()), + apps = apps.take(MAX_LISTED_APPS).map { it.toItem(context) }, + hiddenAppCount = (apps.size - MAX_LISTED_APPS).coerceAtLeast(0) + ) + } private fun AppInfo.toItem(context: Context): WidgetAppItem = WidgetAppItem( name = name, @@ -59,9 +98,8 @@ class DevDrawerGlanceWidget : GlanceAppWidget() { icon = scaledIcon(context) ) - /** Every icon travels in the widget's RemoteViews, so it is scaled to the 40 dp it is drawn at. */ private fun AppInfo.scaledIcon(context: Context): Bitmap? { - val size = (ICON_SIZE_DP * context.resources.displayMetrics.density).toInt() + val size = min((ICON_SIZE_DP * context.resources.displayMetrics.density).toInt(), MAX_ICON_SIZE_PX) return runCatching { appIcon.toBitmap(size, size) }.getOrNull() } } 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 d8d73a23..e7b8ccaa 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 @@ -70,6 +70,15 @@ fun DevDrawerWidgetContent(state: WidgetUiState) { items(state.apps, itemId = { it.packageName.hashCode().toLong() }) { app -> AppRow(context, app) } + if (state.hiddenAppCount > 0) { + item { + Text( + modifier = GlanceModifier.fillMaxWidth().padding(16.dp), + text = context.resources.getQuantityString(R.plurals.widget_more_apps, state.hiddenAppCount, state.hiddenAppCount), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp) + ) + } + } } } } @@ -82,10 +91,11 @@ private fun Header(state: WidgetUiState) { val dark = state.headerColor.dark val container = if (light != null && dark != null) ColorProvider(light.container, dark.container) else GlanceTheme.colors.primaryContainer val content = if (light != null && dark != null) ColorProvider(light.content, dark.content) else GlanceTheme.colors.onPrimaryContainer - val count = if (state.apps.isEmpty()) { + val total = state.apps.size + state.hiddenAppCount + val count = if (total == 0) { context.getString(R.string.widget_no_apps) } else { - context.resources.getQuantityString(R.plurals.widget_app_count, state.apps.size, state.apps.size) + context.resources.getQuantityString(R.plurals.widget_app_count, total, total) } Row( modifier = GlanceModifier.fillMaxWidth().background(container).padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt index dd72d544..165941d4 100644 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt +++ b/app/src/main/java/de/psdev/devdrawer/appwidget/glance/RefreshAction.kt @@ -8,6 +8,6 @@ import androidx.glance.appwidget.action.ActionCallback /** The header's reload button: redraws this widget with the currently installed apps. */ class RefreshAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - DevDrawerGlanceWidget().update(context, glanceId) + DevDrawerGlanceWidget.refresh(context, glanceId) } } 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 6c7916c4..5e0ad8e9 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 @@ -9,7 +9,9 @@ data class WidgetUiState( val title: String, val headerColor: WidgetHeaderColor, val updatedAt: String, - val apps: List + val apps: List, + /** Matching apps beyond the ones listed, which the widget only counts (see DevDrawerGlanceWidget.MAX_LISTED_APPS). */ + val hiddenAppCount: Int = 0 ) data class WidgetAppItem( 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 edad01f3..28501974 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/PackageFilter.kt @@ -33,11 +33,12 @@ data class PackageFilter( @ColumnInfo(name = "profile_id", index = true) val profileId: String ) { + /** Null for a pattern that isn't a valid regex (e.g. "com.(" while typing): it then matches nothing. */ @delegate:Ignore - private val filterRegex: Regex by lazy { filter.replace("*", ".*").toRegex() } + private val filterRegex: Regex? by lazy { runCatching { filter.replace("*", ".*").toRegex() }.getOrNull() } fun matches(packageHashInfo: PackageHashInfo): Boolean = when (type) { - FilterType.PACKAGE_NAME -> filterRegex.matches(packageHashInfo.packageName) + FilterType.PACKAGE_NAME -> filterRegex?.matches(packageHashInfo.packageName) == true FilterType.SIGNATURE -> filter == packageHashInfo.signatureHashSha256 } diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt index ac2846b3..b847fb37 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt @@ -27,12 +27,13 @@ import de.psdev.devdrawer.ui.theme.DevDrawerTheme @Composable fun AppInfoItem( appInfo: AppInfo, - onAppClicked: (AppInfo) -> Unit = {} + /** Null shows the app without making the row clickable. */ + onAppClicked: ((AppInfo) -> Unit)? = null ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { onAppClicked(appInfo) } + .then(if (onAppClicked != null) Modifier.clickable { onAppClicked(appInfo) } else Modifier) .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { 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 23835772..bd2d9e62 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 @@ -2,7 +2,6 @@ package de.psdev.devdrawer.profiles.ui.editor import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,7 +39,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -48,12 +46,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import de.psdev.devdrawer.AppBarActionsProvider import de.psdev.devdrawer.ProvideMenu import de.psdev.devdrawer.R -import de.psdev.devdrawer.appwidget.AppInfo import de.psdev.devdrawer.database.FilterType import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile @@ -250,7 +246,7 @@ internal fun WidgetProfileEditor( Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { SectionTitle(stringResource(R.string.matching_apps, viewState.matchingApps.size)) val apps = if (showAllApps) viewState.matchingApps else viewState.matchingApps.take(COLLAPSED_APP_COUNT) - apps.forEach { app -> MatchingAppRow(app) } + apps.forEach { app -> AppInfoItem(appInfo = app) } if (!showAllApps && viewState.matchingApps.size > COLLAPSED_APP_COUNT) { TextButton(onClick = { showAllApps = true }) { Text(stringResource(R.string.show_all, viewState.matchingApps.size)) @@ -327,25 +323,6 @@ private fun FilterRow(packageFilter: PackageFilter, appCount: Int, onClick: () - } } -@Composable -private fun MatchingAppRow(app: AppInfo) { - Row( - modifier = Modifier.padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val icon = remember(app.packageName) { app.appIcon.toBitmap().asImageBitmap() } - Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(32.dp)) - Column(modifier = Modifier.padding(start = 12.dp)) { - Text(text = app.name, style = MaterialTheme.typography.bodyMedium) - Text( - text = app.packageName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - /** Both filter types side by side, each explained in one sentence, so the choice is by intent. */ @OptIn(ExperimentalMaterial3Api::class) @Composable 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 7d52062c..18ec0895 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 @@ -47,13 +47,20 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( } } + /** Matching apps and per-filter counts; only recomputed when the edited filters or the installed apps change. */ + private val matches = combine(dbFiltersFlow, packageFiltersState, installedPackages) { dbFilters, inMemoryFilters, packages -> + val filters = inMemoryFilters ?: dbFilters + val apps = appsService.appInfos(packages.matching(filters)).sortedWith(SortOrder.NAME.comparator()) + apps to filters.associate { it.id to packages.matching(listOf(it)).size } + } + val state = combine( database.widgetProfileDao().widgetProfileWithIdObservable(profileId), dbFiltersFlow, widgetNameState, packageFiltersState, - installedPackages - ) { widgetProfile, dbPackageFilters, name, inMemoryFilters, packages -> + matches + ) { widgetProfile, dbPackageFilters, name, inMemoryFilters, (matchingApps, filterAppCounts) -> val currentFilters = inMemoryFilters ?: dbPackageFilters val currentName = name ?: widgetProfile?.name.orEmpty() @@ -66,8 +73,8 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( widgetName = currentName, packageFilters = currentFilters, isDirty = nameChanged || filtersChanged, - matchingApps = appsService.appInfos(packages.matching(currentFilters)).sortedWith(SortOrder.NAME.comparator()), - filterAppCounts = currentFilters.associate { it.id to packages.matching(listOf(it)).size } + matchingApps = matchingApps, + filterAppCounts = filterAppCounts ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetProfileEditorViewState.Empty) diff --git a/app/src/main/java/de/psdev/devdrawer/receivers/UpdateReceiver.kt b/app/src/main/java/de/psdev/devdrawer/receivers/UpdateReceiver.kt index 5276a05e..cace1152 100644 --- a/app/src/main/java/de/psdev/devdrawer/receivers/UpdateReceiver.kt +++ b/app/src/main/java/de/psdev/devdrawer/receivers/UpdateReceiver.kt @@ -1,13 +1,16 @@ package de.psdev.devdrawer.receivers -import android.appwidget.AppWidgetManager import android.content.BroadcastReceiver -import android.content.ComponentName import android.content.Context import android.content.Intent -import de.psdev.devdrawer.appwidget.DDWidgetProvider +import de.psdev.devdrawer.appwidget.glance.DevDrawerGlanceWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import mu.KLogging +/** Reloads every placed widget, e.g. after an app was installed or a widget or profile changed. */ class UpdateReceiver: BroadcastReceiver() { companion object: KLogging() { @@ -20,14 +23,13 @@ class UpdateReceiver: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { logger.warn { "onReceive[context=$context, intent=$intent]" } - val appWidgetManager = AppWidgetManager.getInstance(context) - context.sendBroadcast(Intent(context, DDWidgetProvider::class.java).apply { - `package` = context.packageName - action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - putExtra( - AppWidgetManager.EXTRA_APPWIDGET_IDS, - appWidgetManager.getAppWidgetIds(ComponentName(context, DDWidgetProvider::class.java)) - ) - }) + val pendingResult = goAsync() + CoroutineScope(SupervisorJob()).launch(Dispatchers.Default) { + try { + DevDrawerGlanceWidget.refreshAll(context.applicationContext) + } finally { + pendingResult.finish() + } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt index 824f45e2..8bfd3e41 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn @@ -62,27 +64,37 @@ class WidgetEditorViewModel @AssistedInject constructor( } } + private val currentWidget = combine(widgetRepository.widgetFlow(widgetId), editableWidgetState) { persisted, editable -> + editable ?: persisted + } + + /** The first apps and the app count; only recomputed when the profile, sort order, filters or apps change. */ + private val preview = combine( + currentWidget.map { it?.profileId to it?.sortOrder }.distinctUntilChanged(), + packageFilterRepository.allFiltersFlow(), + installedPackages + ) { (profileId, widgetSortOrder), filters, packages -> + val matched = packages.matching(filters.filter { it.profileId == profileId }) + val sortOrder = widgetSortOrder ?: sortOrderSettings.defaultSortOrder() + appsService.appInfos(matched).sortedWith(sortOrder.comparator()).take(PREVIEW_APP_COUNT) to matched.size + } + val state: StateFlow = combine( widgetRepository.widgetFlow(widgetId), + currentWidget, widgetProfileRepository.widgetProfilesFlow(), - packageFilterRepository.allFiltersFlow(), - editableWidgetState, - installedPackages - ) { persistedWidget, widgetProfiles, filters, editableWidget, packages -> - val filtersByProfile = filters.groupBy { it.profileId } - val currentWidget = editableWidget ?: persistedWidget - val defaultSortOrder = sortOrderSettings.defaultSortOrder() - val matched = currentWidget?.let { packages.matching(filtersByProfile[it.profileId].orEmpty()) }.orEmpty() - val sortOrder = currentWidget?.sortOrder ?: defaultSortOrder + combine(packageFilterRepository.allFiltersFlow(), installedPackages) { filters, packages -> + filters.groupBy { it.profileId }.mapValues { (_, profileFilters) -> packages.matching(profileFilters).size } + }, + preview + ) { persistedWidget, widget, widgetProfiles, appCounts, (previewApps, previewAppCount) -> WidgetEditorViewState( persistedWidget = persistedWidget, - editableWidget = currentWidget, - profiles = widgetProfiles.map { profile -> - ProfileWithAppCount(profile, packages.matching(filtersByProfile[profile.id].orEmpty()).size) - }, - previewApps = appsService.appInfos(matched).sortedWith(sortOrder.comparator()).take(PREVIEW_APP_COUNT), - previewAppCount = matched.size, - defaultSortOrder = defaultSortOrder + editableWidget = widget, + profiles = widgetProfiles.map { ProfileWithAppCount(it, appCounts[it.id] ?: 0) }, + previewApps = previewApps, + previewAppCount = previewAppCount, + defaultSortOrder = sortOrderSettings.defaultSortOrder() ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetEditorViewState.Empty) diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt index 448430c8..746d8fd6 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/list/WidgetListScreenViewModel.kt @@ -18,7 +18,11 @@ import de.psdev.devdrawer.profiles.IPackageFilterRepository import de.psdev.devdrawer.profiles.IWidgetProfileRepository import de.psdev.devdrawer.receivers.PinWidgetSuccessReceiver import de.psdev.devdrawer.widgets.IWidgetRepository +import de.psdev.devdrawer.appwidget.PackageHashInfo +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -32,12 +36,20 @@ class WidgetListScreenViewModel @Inject constructor( private val appsService: IAppsService ) : ViewModel() { + private val installedPackages = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + installedPackages.value = appsService.installedPackages(includeSystemApps = true) + } + } + val state = combine( widgetRepository.widgetsFlow(), widgetProfileRepository.widgetProfilesFlow(), - packageFilterRepository.allFiltersFlow() - ) { widgets, profiles, filters -> - val packages = appsService.installedPackages(includeSystemApps = true) + packageFilterRepository.allFiltersFlow(), + installedPackages.filterNotNull() + ) { widgets, profiles, filters, packages -> val profilesById = profiles.associateBy { it.id } val filtersByProfile = filters.groupBy { it.profileId } val appWidgetManager: AppWidgetManager? = application.getSystemService() diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt index 51fff58d..daa65914 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupState.kt @@ -26,10 +26,12 @@ data class WidgetSetupState( val profiles: List = emptyList(), val selectedProfileId: String? = null, /** How many installed apps the widget will list with the current choice. */ - val matchCount: Int = 0 + val matchCount: Int = 0, + /** Done was tapped and the profile and widget are being saved. */ + val isSaving: Boolean = false ) { val canFinish: Boolean - get() = when (source) { + get() = !isSaving && when (source) { SetupSource.MY_APPS -> selectedPackageName != null SetupSource.PATTERN -> pattern.isNotBlank() SetupSource.PROFILE -> selectedProfileId != null diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt index 7dc5908d..d92d5bb4 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModel.kt @@ -105,6 +105,7 @@ class WidgetSetupViewModel @AssistedInject constructor( fun finish(onDone: (Widget) -> Unit) { val state = mutableState.value if (!state.canFinish) return + mutableState.update { it.copy(isSaving = true) } viewModelScope.launch { val profile = when (state.source) { SetupSource.MY_APPS -> { @@ -150,8 +151,6 @@ class WidgetSetupViewModel @AssistedInject constructor( } SetupSource.PROFILE -> selectedProfileId?.let { filtersByProfile[it] } } - // A pattern being typed can be an invalid regex for a moment ("com.("): count no matches until it is valid. - val count = runCatching { installedPackages.matching(filters.orEmpty()).size }.getOrDefault(0) - return copy(matchCount = count) + return copy(matchCount = installedPackages.matching(filters.orEmpty()).size) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4167ad6c..a53103a5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -159,4 +159,8 @@ Passt auf Paketnamen, mit * als Platzhalter. Gut für ein Firmenpräfix wie com.mycompany.* Passende Apps (%1$d) Alle %1$d anzeigen + + +%d weitere App + +%d weitere Apps + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 556f1531..09ecd172 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,4 +199,8 @@ Match package names, with * as a wildcard. Good for a company prefix like com.mycompany.* Matching apps (%1$d) Show all %1$d + + +%d more app + +%d more apps + diff --git a/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt b/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt index 086f880f..6e41b40c 100644 --- a/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/WidgetLaunchTest.kt @@ -42,4 +42,22 @@ class WidgetLaunchTest { // Then assertNull(launch) } + + @Test + fun `given setup opened from outside the app, when leaving it, then the activity closes`() { + // Given / When + val exit = setupExitFor(widgetId = 7, externalSetupWidgetId = 7) + + // Then + assertEquals(SetupExit.FINISH_ACTIVITY, exit) + } + + @Test + fun `given setup opened inside the app, when leaving it, then it goes back`() { + // Given / When + val exit = setupExitFor(widgetId = 7, externalSetupWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID) + + // Then + assertEquals(SetupExit.GO_BACK, exit) + } } diff --git a/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt b/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt index 71ba6e3e..816bd671 100644 --- a/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/apps/AppMatchingTest.kt @@ -58,6 +58,18 @@ class AppMatchingTest { assertEquals(emptyList(), result) } + @Test + fun `given an invalid package pattern, when matching, then it matches nothing instead of failing`() { + // Given + val filters = listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.(", profileId = "p")) + + // When + val result = packages.matching(filters) + + // Then + assertEquals(emptyList(), result) + } + @Test fun `given each sort order, when sorting apps, then they are ordered accordingly`() { // Given diff --git a/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt b/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt index 60f8c8ed..de52a069 100644 --- a/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/appwidget/glance/DevDrawerWidgetContentTest.kt @@ -17,12 +17,13 @@ import org.robolectric.annotation.Config @Config(sdk = [33]) class DevDrawerWidgetContentTest { - private fun state(apps: List) = WidgetUiState( + private fun state(apps: List, hiddenAppCount: Int = 0) = WidgetUiState( appWidgetId = 5, title = "Work apps", headerColor = WidgetHeaderColor.AMBER, updatedAt = "15:12", - apps = apps + apps = apps, + hiddenAppCount = hiddenAppCount ) @Test @@ -64,4 +65,20 @@ class DevDrawerWidgetContentTest { onNode(hasContentDescription("App details for DevDrawer2")).assertExists() onNode(hasText("No apps match this widget")).assertDoesNotExist() } + + @Test + fun `given more apps than the widget lists, when rendered, then a footer counts the rest`() = + runGlanceAppWidgetUnitTest { + // Given + setContext(ApplicationProvider.getApplicationContext()) + setAppWidgetSize(DpSize(300.dp, 300.dp)) + val apps = listOf(WidgetAppItem(name = "DevDrawer2", packageName = "de.psdev.devdrawer", icon = null)) + + // When + provideComposable { DevDrawerWidgetContent(state(apps, hiddenAppCount = 5)) } + + // Then + onNode(hasText("6 apps · updated 15:12")).assertExists() + onNode(hasText("+5 more apps")).assertExists() + } } diff --git a/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt index 98777086..739af6c0 100644 --- a/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupViewModelTest.kt @@ -124,6 +124,26 @@ class WidgetSetupViewModelTest { assertEquals(widget, done) } + @Test + fun `given a picked app, when Done is tapped twice quickly, then only one profile is created`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + advanceUntilIdle() + viewModel.onAppSelected(devDrawer.packageName) + advanceUntilIdle() + + // When + var doneCount = 0 + viewModel.finish { doneCount++ } + viewModel.finish { doneCount++ } + advanceUntilIdle() + + // Then + assertEquals(2, profileRepository.findAll().size) + assertEquals(1, doneCount) + } + @Test fun `given a package pattern, when finishing, then a pattern profile is created`() = runTest { // Given From 2bc4f3df7e9de0df5c7db657c59663dd2bf5278d Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 16:45:33 +0200 Subject: [PATCH 10/15] Pick an app's signature from a bottom sheet The app-signature filter picker was a dialog with an unlabelled icon toggle for system apps. It is now a bottom sheet with a one-line explanation, a search field (name or package), an "Include system apps" chip, and recently updated apps first. Tapping an app adds it; swiping the sheet away cancels. App icons in Compose now go through one AppIcon composable that renders at a fixed size: converting at the drawable's own size failed for drawables without one. Co-Authored-By: Claude Opus 5.5 --- .../ui/editor/AddAppSignatureFilterSheet.kt | 127 ++++++++++++ .../AddAppSignaturePackageFilterDialog.kt | 189 ------------------ ...ppSignaturePackageFilterDialogViewModel.kt | 2 +- .../profiles/ui/editor/AppInfoItem.kt | 14 +- .../profiles/ui/editor/WidgetProfileEditor.kt | 2 +- .../java/de/psdev/devdrawer/ui/AppIcon.kt | 28 +++ .../widgets/ui/editor/WidgetEditorScreen.kt | 8 +- .../widgets/ui/setup/WidgetSetupScreen.kt | 13 +- app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../devdrawer/DialogStringResourcesTest.kt | 4 +- .../editor/AddAppSignatureFilterSheetTest.kt | 107 ++++++++++ .../ui/editor/DialogButtonLabelTest.kt | 18 -- 13 files changed, 282 insertions(+), 242 deletions(-) create mode 100644 app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheet.kt delete mode 100644 app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/ui/AppIcon.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheetTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheet.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheet.kt new file mode 100644 index 00000000..116c0c20 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheet.kt @@ -0,0 +1,127 @@ +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import de.psdev.devdrawer.R +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.database.PackageFilter +import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialogViewModel.ViewState +import de.psdev.devdrawer.ui.loading.LoadingView + +/** Picks an installed app whose signing key becomes a filter: every app signed with the same key then matches. */ +@Composable +fun AddAppSignatureFilterSheet( + currentFilters: List, + viewModel: AddAppSignaturePackageFilterDialogViewModel = hiltViewModel(), + closeDialog: () -> Unit = {}, + appSelected: (AppInfo) -> Unit = {} +) { + val viewState by remember(viewModel) { viewModel.availableApps(currentFilters) } + .collectAsState(initial = ViewState.Loading) + AddAppSignatureFilterSheet( + viewState = viewState, + closeDialog = closeDialog, + appSelected = appSelected, + showSystemApps = { viewModel.showSystemApps.value = it } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AddAppSignatureFilterSheet( + viewState: ViewState, + closeDialog: () -> Unit = {}, + appSelected: (AppInfo) -> Unit = {}, + showSystemApps: (Boolean) -> Unit = {} +) { + var query by rememberSaveable { mutableStateOf("") } + ModalBottomSheet(onDismissRequest = closeDialog) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = stringResource(R.string.select_signature_from_app), style = MaterialTheme.typography.titleLarge) + Text( + text = stringResource(R.string.signature_sheet_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = query, + onValueChange = { query = it }, + singleLine = true, + label = { Text(stringResource(R.string.search_apps)) }, + leadingIcon = { Icon(imageVector = Icons.Filled.Search, contentDescription = null) }, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Ascii) + ) + val includeSystemApps = (viewState as? ViewState.Loaded)?.showSystemApps == true + FilterChip( + selected = includeSystemApps, + enabled = viewState is ViewState.Loaded, + onClick = { showSystemApps(!includeSystemApps) }, + label = { Text(stringResource(R.string.include_system_apps)) }, + leadingIcon = if (includeSystemApps) { + { Icon(imageVector = Icons.Filled.Check, contentDescription = null) } + } else { + null + } + ) + } + when (viewState) { + ViewState.Loading -> LoadingView(modifier = Modifier.fillMaxWidth().padding(24.dp), showText = false) + is ViewState.Error -> Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.error_message, viewState.message) + ) + is ViewState.Loaded -> { + val apps = viewState.data.filter { app -> + query.isBlank() || app.name.contains(query.trim(), ignoreCase = true) || + app.packageName.contains(query.trim(), ignoreCase = true) + } + if (apps.isEmpty()) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource( + if (viewState.data.isEmpty()) R.string.no_apps_available else R.string.search_no_results + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + items(apps, key = { it.packageName }) { app -> + AppInfoItem(appInfo = app, onAppClicked = appSelected) + } + } + } + } + } + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt deleted file mode 100644 index ac91b456..00000000 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialog.kt +++ /dev/null @@ -1,189 +0,0 @@ -package de.psdev.devdrawer.profiles.ui.editor - -import android.content.res.Configuration -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.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.SettingsApplications -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.rememberTooltipState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -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.core.graphics.createBitmap -import androidx.core.graphics.drawable.toDrawable -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import de.psdev.devdrawer.R -import de.psdev.devdrawer.appwidget.AppInfo -import de.psdev.devdrawer.database.PackageFilter -import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialogViewModel.ViewState -import de.psdev.devdrawer.ui.dialog.DefaultDialog -import de.psdev.devdrawer.ui.loading.LoadingView -import de.psdev.devdrawer.ui.theme.DevDrawerTheme - -@Composable -fun AddAppSignaturePackageFilterDialog( - currentFilters: List, - viewModel: AddAppSignaturePackageFilterDialogViewModel = hiltViewModel(), - closeDialog: () -> Unit = {}, - appSelected: (AppInfo) -> Unit = {} -) { - val viewState by remember(viewModel) { viewModel.availableApps(currentFilters) } - .collectAsState(initial = ViewState.Loading) - AddAppSignaturePackageFilterDialog( - viewState = viewState, - closeDialog = closeDialog, - appSelected = appSelected, - showSystemApps = { viewModel.showSystemApps.value = it } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun AddAppSignaturePackageFilterDialog( - viewState: ViewState, - closeDialog: () -> Unit = {}, - appSelected: (AppInfo) -> Unit = {}, - showSystemApps: (Boolean) -> Unit = {} -) { - DefaultDialog( - onDismissRequest = closeDialog, - titleContent = { - 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.select_signature_from_app)) - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - PlainTooltip { - Text(text = stringResource(id = R.string.toggle_system_apps)) - } - }, - state = rememberTooltipState() - ) { - IconButton( - enabled = viewState is ViewState.Loaded, - onClick = { - if (viewState is ViewState.Loaded) { - showSystemApps(!viewState.showSystemApps) - } - }) { - Icon( - imageVector = Icons.Filled.SettingsApplications, - contentDescription = stringResource(id = R.string.include_system_apps) - ) - } - } - } - }, - bottomContent = { - TextButton( - modifier = Modifier - .align(Alignment.End), onClick = closeDialog - ) { - Text(text = stringResource(id = R.string.cancel)) - } - } - ) { - when (viewState) { - ViewState.Loading -> LoadingView( - modifier = Modifier - .align(CenterHorizontally) - .wrapContentHeight(), - showText = false - ) - - is ViewState.Loaded -> { - if (viewState.data.isEmpty()) { - Text( - modifier = Modifier.padding(8.dp), - text = stringResource(id = R.string.no_apps_available) - ) - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f, false) - ) { - items(viewState.data) { - AppInfoItem(appInfo = it, onAppClicked = appSelected) - } - } - } - } - - is ViewState.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_AddAppSignaturePackageFilterDialog() { - DevDrawerTheme { - Surface { - AddAppSignaturePackageFilterDialog( - viewState = ViewState.Loaded( - emptyList(), - false - ) - ) - } - } -} - -@Preview(showSystemUi = true) -@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun Preview_AddAppSignaturePackageFilterDialog_Apps() { - DevDrawerTheme { - Surface { - val resources = LocalResources.current - val bitmap = createBitmap(10, 10) - AddAppSignaturePackageFilterDialog( - viewState = ViewState.Loaded( - (1..12).map { i -> - AppInfo( - name = "App $i", - packageName = "com.example.app$i", - bitmap.toDrawable(resources), - 0, - 0, - "" - ) - }, - false - ) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt index a9bd0839..02b8e0b7 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignaturePackageFilterDialogViewModel.kt @@ -29,7 +29,7 @@ class AddAppSignaturePackageFilterDialogViewModel @Inject constructor( val availableApps = appsService.getInstalledPackages(showSystemApps) .filter { currentFilters.none { packageFilter -> packageFilter.matches(it) } } .mapNotNull { it.toAppInfo(application) } - .sortedBy { it.name } + .sortedByDescending { it.lastUpdateTime } emit(ViewState.Loaded(availableApps, showSystemApps)) }.flowOn(Dispatchers.IO) diff --git a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt index b847fb37..7f163e00 100644 --- a/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt +++ b/app/src/main/java/de/psdev/devdrawer/profiles/ui/editor/AppInfoItem.kt @@ -1,6 +1,5 @@ package de.psdev.devdrawer.profiles.ui.editor -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,17 +10,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.LocalResources import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap -import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toDrawable import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.ui.AppIcon import de.psdev.devdrawer.ui.theme.DevDrawerTheme @Composable @@ -37,14 +34,7 @@ fun AppInfoItem( .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { - val icon = remember(appInfo.appIcon) { - appInfo.appIcon.toBitmap().asImageBitmap() - } - Image( - bitmap = icon, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) + AppIcon(icon = appInfo.appIcon, size = 48.dp) Column( modifier = Modifier .padding(start = 8.dp) 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 bd2d9e62..18d9b5ff 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 @@ -105,7 +105,7 @@ fun WidgetProfileEditor( onSignatureClick = { currentDialog = WidgetProfileEditorDialogs.AddAppSignaturePackageFilter(viewState.packageFilters) }, onPatternClick = { currentDialog = WidgetProfileEditorDialogs.AddPackageNamePackageFilter(viewState.packageFilters) } ) - is WidgetProfileEditorDialogs.AddAppSignaturePackageFilter -> AddAppSignaturePackageFilterDialog( + is WidgetProfileEditorDialogs.AddAppSignaturePackageFilter -> AddAppSignatureFilterSheet( currentFilters = dialog.currentPackageFilters, closeDialog = { currentDialog = WidgetProfileEditorDialogs.None diff --git a/app/src/main/java/de/psdev/devdrawer/ui/AppIcon.kt b/app/src/main/java/de/psdev/devdrawer/ui/AppIcon.kt new file mode 100644 index 00000000..ca3bb2a2 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/ui/AppIcon.kt @@ -0,0 +1,28 @@ +package de.psdev.devdrawer.ui + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.core.graphics.drawable.toBitmap + +/** + * An app's icon at [size]. Rendered at that size rather than the drawable's own, which is missing for some drawables + * (and would then fail to convert); an empty space of the same size when there is no icon. + */ +@Composable +fun AppIcon(icon: Drawable?, size: Dp, modifier: Modifier = Modifier) { + val sizePx = with(LocalDensity.current) { size.roundToPx() } + val bitmap = remember(icon, sizePx) { icon?.toBitmap(sizePx, sizePx)?.asImageBitmap() } + if (bitmap != null) { + Image(bitmap = bitmap, contentDescription = null, modifier = modifier.size(size)) + } else { + Spacer(modifier.size(size)) + } +} diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt index a3a0fda9..a6223106 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt @@ -5,7 +5,6 @@ import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -37,10 +36,8 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.res.pluralStringResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -51,7 +48,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.psdev.devdrawer.AppBarActionsProvider @@ -65,6 +61,7 @@ import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.profiles.ProfileWithAppCount import de.psdev.devdrawer.settings.ListPreference import de.psdev.devdrawer.settings.label +import de.psdev.devdrawer.ui.AppIcon import de.psdev.devdrawer.ui.theme.DevDrawerTheme import de.psdev.devdrawer.widgets.ui.widgetHeaderColors @@ -241,8 +238,7 @@ private fun WidgetPreview(widget: Widget, apps: List, appCount: Int) { modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - val icon = remember(app.packageName) { app.appIcon.toBitmap().asImageBitmap() } - Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(32.dp)) + AppIcon(icon = app.appIcon, size = 32.dp) Text( modifier = Modifier.padding(start = 12.dp), text = app.name, diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt index 3d8a6ea6..b2d3ffdf 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/setup/WidgetSetupScreen.kt @@ -1,11 +1,9 @@ package de.psdev.devdrawer.widgets.ui.setup -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -25,23 +23,21 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.psdev.devdrawer.R import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.AppIcon import de.psdev.devdrawer.ui.loading.LoadingView private const val COLLAPSED_APP_COUNT = 5 @@ -213,12 +209,7 @@ private fun AppOption(app: SetupApp, selected: Boolean, onClick: () -> Unit) { app.packageName } ChoiceRow(selected = selected, onClick = onClick, title = app.name, subtitle = subtitle) { - val icon = remember(app.icon) { app.icon?.toBitmap()?.asImageBitmap() } - if (icon != null) { - Image(bitmap = icon, contentDescription = null, modifier = Modifier.size(40.dp)) - } else { - Spacer(Modifier.size(40.dp)) - } + AppIcon(icon = app.icon, size = 40.dp) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a53103a5..aa3b6620 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -50,7 +50,6 @@ Leeren Keine Apps verfügbar oder alle bereits als Filter hinzugefügt Mehr - System-Apps ein-/ausblenden System-Apps einbeziehen Änderungen verwerfen? Du hast ungespeicherte Änderungen. Möchtest du sie verwerfen? @@ -163,4 +162,9 @@ +%d weitere App +%d weitere Apps + + + Jede App, die mit demselben Schlüssel signiert ist, passt. + Apps suchen + Keine App passt zur Suche diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09ecd172..98e11eb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,7 +100,6 @@ No apps available or all already added as filter More - Toggle system apps Include system apps Discard changes? @@ -203,4 +202,9 @@ +%d more app +%d more apps + + + Every app signed with the same key will match. + Search apps + No apps match your search diff --git a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt index c04cdc70..26793728 100644 --- a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt @@ -9,7 +9,7 @@ import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.profiles.DeleteDialogState import de.psdev.devdrawer.profiles.WidgetInUseErrorAlertDialog -import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialog +import de.psdev.devdrawer.profiles.ui.editor.AddAppSignatureFilterSheet import de.psdev.devdrawer.profiles.ui.editor.AddAppSignaturePackageFilterDialogViewModel import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialog import de.psdev.devdrawer.profiles.ui.editor.AddPackageNamePackageFilterDialogViewModel @@ -89,7 +89,7 @@ class DialogStringResourcesTest { // Given / When composeTestRule.setContent { DevDrawerTheme { - AddAppSignaturePackageFilterDialog( + AddAppSignatureFilterSheet( viewState = AddAppSignaturePackageFilterDialogViewModel.ViewState.Error("kaputt") ) AddPackageNamePackageFilterDialog( diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheetTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheetTest.kt new file mode 100644 index 00000000..1e58be07 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/AddAppSignatureFilterSheetTest.kt @@ -0,0 +1,107 @@ +package de.psdev.devdrawer.profiles.ui.editor + +import android.graphics.drawable.ColorDrawable +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import de.psdev.devdrawer.appwidget.AppInfo +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class AddAppSignatureFilterSheetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun app(name: String, packageName: String) = AppInfo( + name = name, + packageName = packageName, + appIcon = ColorDrawable(0), + firstInstallTime = 0, + lastUpdateTime = 0, + signatureHashSha256 = "key-$name" + ) + + private val devDrawer = app("DevDrawer2", "de.psdev.devdrawer") + private val client = app("Client", "com.example.client") + + private fun showSheet( + showSystemApps: Boolean = false, + onShowSystemApps: (Boolean) -> Unit = {}, + onAppSelected: (AppInfo) -> Unit = {} + ) { + composeTestRule.setContent { + DevDrawerTheme { + AddAppSignatureFilterSheet( + viewState = AddAppSignaturePackageFilterDialogViewModel.ViewState.Loaded( + data = listOf(devDrawer, client), + showSystemApps = showSystemApps + ), + appSelected = onAppSelected, + showSystemApps = onShowSystemApps + ) + } + } + } + + @Test + fun `given installed apps, when the sheet opens, then it explains the choice and lists the apps`() { + // Given / When + showSheet() + + // Then + composeTestRule.onNodeWithText("Select signature from app").assertExists() + composeTestRule.onNodeWithText("Every app signed with the same key will match.").assertExists() + composeTestRule.onNodeWithText("Include system apps").assertExists() + composeTestRule.onNodeWithText("DevDrawer2").assertExists() + composeTestRule.onNodeWithText("Client").assertExists() + } + + @Test + fun `given a search, when typing part of a package name, then only matching apps remain`() { + // Given + showSheet() + + // When + composeTestRule.onNodeWithText("Search apps").performTextInput("example") + + // Then + composeTestRule.onNodeWithText("Client").assertExists() + composeTestRule.onNodeWithText("DevDrawer2").assertDoesNotExist() + } + + @Test + fun `given the sheet, when an app is tapped, then it is selected`() { + // Given + var selected: AppInfo? = null + showSheet(onAppSelected = { selected = it }) + + // When + composeTestRule.onNodeWithText("DevDrawer2").performClick() + + // Then + assertEquals(devDrawer, selected) + } + + @Test + fun `given system apps hidden, when the chip is tapped, then system apps are requested`() { + // Given + var requested: Boolean? = null + showSheet(showSystemApps = false, onShowSystemApps = { requested = it }) + + // When + composeTestRule.onNodeWithText("Include system apps").performClick() + + // Then + assertEquals(true, requested) + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt index 41287f9f..62fb06e8 100644 --- a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/DialogButtonLabelTest.kt @@ -44,24 +44,6 @@ class DialogButtonLabelTest { assertSentenceCase("Cancel", "Add") } - @Test - fun `given the add app signature filter dialog, when shown, then its button is in sentence case`() { - // Given / When - composeTestRule.setContent { - DevDrawerTheme { - AddAppSignaturePackageFilterDialog( - viewState = AddAppSignaturePackageFilterDialogViewModel.ViewState.Loaded( - data = emptyList(), - showSystemApps = false - ) - ) - } - } - - // Then - assertSentenceCase("Cancel") - } - @Test fun `given the filter info dialog, when shown, then its button is in sentence case`() { // Given / When From 2b3188108eec16408ed4109ea234cdfc93324ace Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 17:07:56 +0200 Subject: [PATCH 11/15] Add red, green, blue, teal and purple widget header colours Legacy widget colours now migrate to their closest header colour instead of falling back to amber, and the editor's swatches wrap onto a second row. Co-Authored-By: Claude Opus 5.5 --- .../de/psdev/devdrawer/database/Migrations.kt | 16 ++++- .../devdrawer/database/WidgetHeaderColor.kt | 2 +- .../devdrawer/ui/theme/WidgetHeaderPalette.kt | 10 +++ .../widgets/ui/editor/WidgetEditorScreen.kt | 66 ++++++++++--------- app/src/main/res/values-de/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 5 ++ .../database/MigrationFrom3To4Test.kt | 19 ++++-- .../ui/theme/WidgetHeaderPaletteTest.kt | 54 +++++++++++++++ 8 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 app/src/test/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPaletteTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt index ae859de9..7b807b65 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/Migrations.kt @@ -48,11 +48,23 @@ object MigrationFrom2To3 : Migration(2, 3) { } } -/** Adds the curated header colour and the per-widget sort order. Dark legacy colours keep a dark header. */ +/** Adds the curated header colour and the per-widget sort order. Legacy colours map to the closest header colour. */ object MigrationFrom3To4 : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE `widgets` ADD COLUMN `header_color` TEXT NOT NULL DEFAULT 'AMBER'") db.execSQL("ALTER TABLE `widgets` ADD COLUMN `sort_order` TEXT") - db.execSQL("UPDATE `widgets` SET `header_color` = 'DARK' WHERE `color` IN (${Color.BLACK}, ${Color.DKGRAY})") + legacyHeaderColors.forEach { (headerColor, legacyColors) -> + db.execSQL("UPDATE `widgets` SET `header_color` = '$headerColor' WHERE `color` IN (${legacyColors.joinToString()})") + } } + + private val legacyHeaderColors = mapOf( + "DARK" to listOf(Color.BLACK, Color.DKGRAY), + "NEUTRAL" to listOf(Color.GRAY, Color.LTGRAY, Color.WHITE), + "RED" to listOf(Color.RED), + "GREEN" to listOf(Color.GREEN), + "BLUE" to listOf(Color.BLUE), + "TEAL" to listOf(Color.CYAN), + "PURPLE" to listOf(Color.MAGENTA) + ) } diff --git a/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt b/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt index d706bb72..146bfaa4 100644 --- a/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt +++ b/app/src/main/java/de/psdev/devdrawer/database/WidgetHeaderColor.kt @@ -5,5 +5,5 @@ package de.psdev.devdrawer.database * with readable text; [DYNAMIC] follows the wallpaper (Material You) on Android 12+. */ enum class WidgetHeaderColor { - AMBER, NEUTRAL, DARK, DYNAMIC + AMBER, NEUTRAL, DARK, RED, GREEN, BLUE, TEAL, PURPLE, DYNAMIC } diff --git a/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt b/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt index 3c701e5e..52814ef5 100644 --- a/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt +++ b/app/src/main/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPalette.kt @@ -12,6 +12,11 @@ val WidgetHeaderColor.light: HeaderColorPair? WidgetHeaderColor.AMBER -> HeaderColorPair(Amber200, Color(0xFF261900)) WidgetHeaderColor.NEUTRAL -> HeaderColorPair(Color(0xFFE9E1D9), Color(0xFF1E1B16)) WidgetHeaderColor.DARK -> HeaderColorPair(Color(0xFF1E1B16), Color(0xFFFFFFFF)) + WidgetHeaderColor.RED -> HeaderColorPair(Color(0xFFFFDAD6), Color(0xFF410002)) + WidgetHeaderColor.GREEN -> HeaderColorPair(Color(0xFFB8F397), Color(0xFF042100)) + WidgetHeaderColor.BLUE -> HeaderColorPair(Color(0xFFD1E4FF), Color(0xFF001D36)) + WidgetHeaderColor.TEAL -> HeaderColorPair(Color(0xFF6FF7F6), Color(0xFF002020)) + WidgetHeaderColor.PURPLE -> HeaderColorPair(Color(0xFFEADDFF), Color(0xFF21005D)) WidgetHeaderColor.DYNAMIC -> null } @@ -21,5 +26,10 @@ val WidgetHeaderColor.dark: HeaderColorPair? WidgetHeaderColor.AMBER -> HeaderColorPair(Color(0xFF5E4200), Amber200) WidgetHeaderColor.NEUTRAL -> HeaderColorPair(Color(0xFF38342E), Color(0xFFE9E1D9)) WidgetHeaderColor.DARK -> HeaderColorPair(Color(0xFF000000), Color(0xFFE9E1D9)) + WidgetHeaderColor.RED -> HeaderColorPair(Color(0xFF93000A), Color(0xFFFFDAD6)) + WidgetHeaderColor.GREEN -> HeaderColorPair(Color(0xFF205107), Color(0xFFB8F397)) + WidgetHeaderColor.BLUE -> HeaderColorPair(Color(0xFF00497D), Color(0xFFD1E4FF)) + WidgetHeaderColor.TEAL -> HeaderColorPair(Color(0xFF004F4F), Color(0xFF6FF7F6)) + WidgetHeaderColor.PURPLE -> HeaderColorPair(Color(0xFF4F378B), Color(0xFFEADDFF)) WidgetHeaderColor.DYNAMIC -> null } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt index a6223106..2bad50f0 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -261,44 +262,45 @@ private fun HeaderColorPicker(selected: WidgetHeaderColor, onSelected: (WidgetHe WidgetHeaderColor.AMBER to stringResource(R.string.header_color_amber), WidgetHeaderColor.NEUTRAL to stringResource(R.string.header_color_neutral), WidgetHeaderColor.DARK to stringResource(R.string.header_color_dark), + WidgetHeaderColor.RED to stringResource(R.string.header_color_red), + WidgetHeaderColor.GREEN to stringResource(R.string.header_color_green), + WidgetHeaderColor.BLUE to stringResource(R.string.header_color_blue), + WidgetHeaderColor.TEAL to stringResource(R.string.header_color_teal), + WidgetHeaderColor.PURPLE to stringResource(R.string.header_color_purple), WidgetHeaderColor.DYNAMIC to stringResource(R.string.header_color_dynamic) ) - Row( - modifier = Modifier - .fillMaxWidth() - .selectableGroup(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - modifier = Modifier.weight(1f), - text = stringResource(R.string.header_color), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - options.forEach { option -> - val colors = widgetHeaderColors(option) - val isSelected = option == selected - Box( - modifier = Modifier - .size(48.dp) - .selectable(selected = isSelected, onClick = { onSelected(option) }, role = Role.RadioButton) - .semantics { contentDescription = labels.getValue(option) }, - contentAlignment = Alignment.Center - ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle(stringResource(R.string.header_color)) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + options.forEach { option -> + val colors = widgetHeaderColors(option) + val isSelected = option == selected Box( modifier = Modifier - .size(40.dp) - .background(colors.container, MaterialTheme.shapes.small) - .border( - width = if (isSelected) 3.dp else 1.dp, - color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - shape = MaterialTheme.shapes.small - ), + .size(48.dp) + .selectable(selected = isSelected, onClick = { onSelected(option) }, role = Role.RadioButton) + .semantics { contentDescription = labels.getValue(option) }, contentAlignment = Alignment.Center ) { - if (option == WidgetHeaderColor.DYNAMIC) { - Icon(imageVector = Icons.Default.Palette, contentDescription = null, tint = colors.content) + Box( + modifier = Modifier + .size(40.dp) + .background(colors.container, MaterialTheme.shapes.small) + .border( + width = if (isSelected) 3.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + shape = MaterialTheme.shapes.small + ), + contentAlignment = Alignment.Center + ) { + if (option == WidgetHeaderColor.DYNAMIC) { + Icon(imageVector = Icons.Default.Palette, contentDescription = null, tint = colors.content) + } } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aa3b6620..d40c5af0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -124,6 +124,11 @@ Bernstein Neutral Dunkel + Rot + Grün + Blau + Petrol + Lila Farben des Hintergrundbilds Apps Sortieren nach diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98e11eb4..ee498808 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,11 @@ Amber Neutral Dark + Red + Green + Blue + Teal + Purple Wallpaper colours Apps Sort by diff --git a/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt b/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt index eef1c7c5..e3210406 100644 --- a/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt +++ b/app/src/test/java/de/psdev/devdrawer/database/MigrationFrom3To4Test.kt @@ -23,13 +23,22 @@ class MigrationFrom3To4Test { ) @Test - fun `given widgets with legacy colours, when migrating to 4, then dark colours become the dark header and others amber`() { + fun `given widgets with legacy colours, when migrating to 4, then each legacy colour maps to its closest header colour`() { // Given helper.createDatabase(TEST_DB, 3).use { db -> db.execSQL("INSERT INTO `widget_profiles` (`id`, `name`, `updatedAt`) VALUES ('p1', 'Default', 0)") - db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (1, 'Black', ${Color.BLACK}, 'p1')") - db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (2, 'Dark grey', ${Color.DKGRAY}, 'p1')") - db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (3, 'Yellow', ${Color.YELLOW}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (1, 'BLACK', ${Color.BLACK}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (2, 'DKGRAY', ${Color.DKGRAY}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (3, 'GRAY', ${Color.GRAY}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (4, 'LTGRAY', ${Color.LTGRAY}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (5, 'WHITE', ${Color.WHITE}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (6, 'RED', ${Color.RED}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (7, 'GREEN', ${Color.GREEN}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (8, 'BLUE', ${Color.BLUE}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (9, 'YELLOW', ${Color.YELLOW}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (10, 'CYAN', ${Color.CYAN}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (11, 'MAGENTA', ${Color.MAGENTA}, 'p1')") + db.execSQL("INSERT INTO `widgets` (`id`, `name`, `color`, `profile_id`) VALUES (12, 'Custom', ${0xFF123456.toInt()}, 'p1')") } // When @@ -43,7 +52,7 @@ class MigrationFrom3To4Test { assertNull(cursor.getString(2)) } } - assertEquals(mapOf(1 to "DARK", 2 to "DARK", 3 to "AMBER"), headerColors) + assertEquals(mapOf(1 to "DARK", 2 to "DARK", 3 to "NEUTRAL", 4 to "NEUTRAL", 5 to "NEUTRAL", 6 to "RED", 7 to "GREEN", 8 to "BLUE", 9 to "AMBER", 10 to "TEAL", 11 to "PURPLE", 12 to "AMBER"), headerColors) } private companion object { diff --git a/app/src/test/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPaletteTest.kt b/app/src/test/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPaletteTest.kt new file mode 100644 index 00000000..ca4f976a --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/ui/theme/WidgetHeaderPaletteTest.kt @@ -0,0 +1,54 @@ +package de.psdev.devdrawer.ui.theme + +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils +import de.psdev.devdrawer.database.WidgetHeaderColor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetHeaderPaletteTest { + + private val fixedColors = WidgetHeaderColor.entries - WidgetHeaderColor.DYNAMIC + + @Test + fun `given the palette, when listing it, then it offers the colourful options besides amber, neutral and dark`() { + // Given / When + val names = WidgetHeaderColor.entries.map { it.name } + + // Then + assertEquals( + listOf("AMBER", "NEUTRAL", "DARK", "RED", "GREEN", "BLUE", "TEAL", "PURPLE", "DYNAMIC"), + names + ) + } + + @Test + fun `given each fixed colour, when reading its light and dark pairs, then text is readable at 4_5 to 1`() { + // Given / When + val pairs = fixedColors.flatMap { color -> listOf(color to color.light!!, color to color.dark!!) } + + // Then + pairs.forEach { (color, pair) -> + val contrast = ColorUtils.calculateContrast(pair.content.toArgb(), pair.container.toArgb()) + assertTrue("$color contrast $contrast", contrast >= 4.5) + } + } + + @Test + fun `given the fixed colours, when comparing their containers, then each one looks different`() { + // Given / When + val light = fixedColors.map { it.light!!.container } + val dark = fixedColors.map { it.dark!!.container } + + // Then + assertEquals(fixedColors.size, light.toSet().size) + assertEquals(fixedColors.size, dark.toSet().size) + } +} From 0bc8a1568c8e43265eefa8ef360deae5fe7a13fd Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 17:40:47 +0200 Subject: [PATCH 12/15] Ask for analytics consent in a card on the Widgets tab The blocking opt-in dialog could cover a new widget's setup and kept its buttons disabled for 2.5 seconds. It is now a dismissable card that only appears on the Widgets tab. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/DevDrawerApp.kt | 102 +++++------------- .../devdrawer/analytics/AnalyticsOptInCard.kt | 56 ++++++++++ app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../devdrawer/DialogStringResourcesTest.kt | 9 +- .../analytics/AnalyticsOptInCardTest.kt | 63 +++++++++++ 6 files changed, 157 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/de/psdev/devdrawer/analytics/AnalyticsOptInCard.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/analytics/AnalyticsOptInCardTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt index 811311a9..9fc49b3a 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.StringRes import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -12,7 +13,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Grid3x3 import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Widgets -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,30 +25,26 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import de.psdev.devdrawer.analytics.AnalyticsOptInCard import de.psdev.devdrawer.analytics.TrackingService +import de.psdev.devdrawer.analytics.showsAnalyticsOptIn import de.psdev.devdrawer.database.Widget import de.psdev.devdrawer.settings.SettingsViewModel import de.psdev.devdrawer.settings.ThemeSetting import de.psdev.devdrawer.ui.theme.DevDrawerTheme -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import mu.KotlinLogging @@ -113,22 +109,16 @@ fun DevDrawerApp( } val needsOptIn by trackingService.needsOptIn.collectAsState() - if (needsOptIn) { - AnalyticsOptInDialog( - onOptIn = { - trackingService.optIn() - scope.launch { - snackbarHostState.showSnackbar( - message = optInThanksMessage, - actionLabel = okLabel, - duration = SnackbarDuration.Long - ) - } - }, - onOptOut = { - trackingService.optOut() - } - ) + val onOptIn = { + trackingService.optIn() + scope.launch { + snackbarHostState.showSnackbar( + message = optInThanksMessage, + actionLabel = okLabel, + duration = SnackbarDuration.Long + ) + } + Unit } Scaffold( @@ -150,14 +140,19 @@ fun DevDrawerApp( ) }, content = { innerPadding -> - DevDrawerHost( - navigationState = navigationState, - navigator = navigator, - menuCallback = setMenu, - modifier = Modifier.padding(innerPadding), - onWidgetSetupDone = onWidgetSetupDone, - onWidgetSetupBack = onWidgetSetupBack - ) + Column(modifier = Modifier.padding(innerPadding)) { + if (showsAnalyticsOptIn(needsOptIn, currentRoute)) { + AnalyticsOptInCard(onOptIn = onOptIn, onOptOut = trackingService::optOut) + } + DevDrawerHost( + navigationState = navigationState, + navigator = navigator, + menuCallback = setMenu, + modifier = Modifier.weight(1f), + onWidgetSetupDone = onWidgetSetupDone, + onWidgetSetupBack = onWidgetSetupBack + ) + } }, bottomBar = { NavigationBar { @@ -183,49 +178,6 @@ fun DevDrawerApp( } } -@Composable -fun AnalyticsOptInDialog( - onOptIn: () -> Unit, - onOptOut: () -> Unit -) { - // Persist the dialog-show timestamp across config changes so rotation doesn't restart the delay. - val shownAt by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } - var buttonsEnabled by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - if (!buttonsEnabled) { - val remaining = 2500L - (System.currentTimeMillis() - shownAt) - if (remaining > 0) delay(remaining) - buttonsEnabled = true - } - } - - AlertDialog( - onDismissRequest = { /* Not cancelable */ }, - title = { - Text(text = stringResource(id = R.string.analytics_opt_in_title), fontWeight = FontWeight.Bold) - }, - text = { - Text(text = stringResource(id = R.string.analytics_opt_in_message)) - }, - confirmButton = { - TextButton( - enabled = buttonsEnabled, - onClick = onOptIn - ) { - Text(stringResource(id = R.string.analytics_opt_in)) - } - }, - dismissButton = { - TextButton( - enabled = buttonsEnabled, - onClick = onOptOut - ) { - Text(stringResource(id = R.string.analytics_opt_out)) - } - } - ) -} - val topLevelRoutes = listOf( WidgetListRoute, WidgetProfilesRoute, diff --git a/app/src/main/java/de/psdev/devdrawer/analytics/AnalyticsOptInCard.kt b/app/src/main/java/de/psdev/devdrawer/analytics/AnalyticsOptInCard.kt new file mode 100644 index 00000000..8c766c7b --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/analytics/AnalyticsOptInCard.kt @@ -0,0 +1,56 @@ +package de.psdev.devdrawer.analytics + +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.unit.dp +import androidx.navigation3.runtime.NavKey +import de.psdev.devdrawer.R +import de.psdev.devdrawer.WidgetListRoute +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import de.psdev.devdrawer.utils.DefaultPreviews + +/** The opt-in waits on the Widgets tab instead of interrupting, so it never covers a widget's setup. */ +fun showsAnalyticsOptIn(needsOptIn: Boolean, currentRoute: NavKey): Boolean = + needsOptIn && currentRoute == WidgetListRoute + +@Composable +fun AnalyticsOptInCard( + onOptIn: () -> Unit, + onOptOut: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.analytics_opt_in_title), style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.analytics_opt_in_text), style = MaterialTheme.typography.bodyMedium) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + TextButton(onClick = onOptOut) { Text(stringResource(R.string.analytics_opt_out)) } + Button(onClick = onOptIn) { Text(stringResource(R.string.analytics_opt_in)) } + } + } + } +} + +@DefaultPreviews +@Composable +private fun Preview_AnalyticsOptInCard() { + DevDrawerTheme { AnalyticsOptInCard(onOptIn = {}, onOptOut = {}) } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d40c5af0..14a04d9a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -69,9 +69,9 @@ Option auswählen Nutzungsanalyse - Damit wir besser verstehen, wie du die App verwendest, würden wir deine Nutzung gerne analysieren.\n\nWir verwenden Firebase Analytics, um geöffnete Bildschirme und bestimmte Interaktionen zu erfassen.\n\nWir speichern keine personenbezogenen Daten.\n\nZusätzlich verwenden wir Firebase Crashlytics für App-Abstürze.\n\nDanke, dass du darüber nachdenkst! - Zustimmen - Ablehnen + Hilf, DevDrawer zu verbessern, indem du teilst, welche Bildschirme du öffnest, und Absturzberichte sendest (über Firebase). Keine personenbezogenen Daten. Du kannst das jederzeit in den Einstellungen ändern. + Erlauben + Nein danke Danke! Du kannst deine Entscheidung jederzeit im Einstellungen-Tab ändern. Fehler: %1$s Das Profil %1$s wird verwendet von: \n%2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee498808..8fd90077 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,9 +109,9 @@ Select option Usage analytics - In order for us to be able to better understand your use of the app we would like to analyse your usage.\n\nWe use Firebase Analytics to track opened screens and certain interactions.\n\nWe don\'t store personally identifiable data.\n\nAdditionally we use Firebase Crashlytics for app crashes.\n\nThank you for considering! - Opt-in - Opt-out + Help improve DevDrawer by sharing which screens you open and crash reports, via Firebase. No personal data. You can change this anytime in Settings. + Allow + No thanks Thank you! You can change your decision anytime on the settings tab. Error: %1$s The profile %1$s is used by: \n%2$s diff --git a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt index 26793728..45488be6 100644 --- a/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/DialogStringResourcesTest.kt @@ -1,5 +1,6 @@ package de.psdev.devdrawer +import de.psdev.devdrawer.analytics.AnalyticsOptInCard import android.graphics.Color import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText @@ -35,15 +36,15 @@ class DialogStringResourcesTest { private val widgetProfile = WidgetProfile(name = "Arbeit") @Test - fun `given a German locale, when showing the analytics opt-in dialog, then it reads German`() { + fun `given a German locale, when showing the analytics opt-in card, then it reads German`() { // Given / When composeTestRule.setContent { - DevDrawerTheme { AnalyticsOptInDialog(onOptIn = {}, onOptOut = {}) } + DevDrawerTheme { AnalyticsOptInCard(onOptIn = {}, onOptOut = {}) } } // Then - assertTexts("Nutzungsanalyse", "Zustimmen", "Ablehnen") - composeTestRule.onNodeWithText("Wir verwenden Firebase Analytics", substring = true).assertExists() + assertTexts("Nutzungsanalyse", "Erlauben", "Nein danke") + composeTestRule.onNodeWithText("Keine personenbezogenen Daten", substring = true).assertExists() } @Test diff --git a/app/src/test/java/de/psdev/devdrawer/analytics/AnalyticsOptInCardTest.kt b/app/src/test/java/de/psdev/devdrawer/analytics/AnalyticsOptInCardTest.kt new file mode 100644 index 00000000..5e5b296e --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/analytics/AnalyticsOptInCardTest.kt @@ -0,0 +1,63 @@ +package de.psdev.devdrawer.analytics + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import de.psdev.devdrawer.SettingsRoute +import de.psdev.devdrawer.WidgetListRoute +import de.psdev.devdrawer.WidgetSetupRoute +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class AnalyticsOptInCardTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `given a pending opt-in, when on the Widgets tab, then the card is shown`() { + assertTrue(showsAnalyticsOptIn(needsOptIn = true, currentRoute = WidgetListRoute)) + } + + @Test + fun `given a pending opt-in, when setting up a widget or elsewhere, then nothing interrupts`() { + assertFalse(showsAnalyticsOptIn(needsOptIn = true, currentRoute = WidgetSetupRoute(5))) + assertFalse(showsAnalyticsOptIn(needsOptIn = true, currentRoute = SettingsRoute)) + } + + @Test + fun `given no pending opt-in, when on the Widgets tab, then the card is hidden`() { + assertFalse(showsAnalyticsOptIn(needsOptIn = false, currentRoute = WidgetListRoute)) + } + + @Test + fun `given the card, when shown, then it explains the choice and both answers work right away`() { + // Given + val answers = mutableListOf() + composeTestRule.setContent { + DevDrawerTheme { + AnalyticsOptInCard(onOptIn = { answers += "in" }, onOptOut = { answers += "out" }) + } + } + + // When + composeTestRule.onNodeWithText("Usage analytics").assertExists() + composeTestRule.onNodeWithText("No personal data", substring = true).assertExists() + composeTestRule.onNodeWithText("No thanks").assertIsEnabled().performClick() + composeTestRule.onNodeWithText("Allow").assertIsEnabled().performClick() + + // Then + assertEquals(listOf("out", "in"), answers) + } +} From 3756485be2011fefed7acbe4dba0d7d5ff86f7ef Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 17:42:24 +0200 Subject: [PATCH 13/15] Open setup for widgets added from the app Widgets placed with the app's "Add widget" button were saved straight away with the first profile (or a new empty "Default" one) and never went through setup. The launcher's confirmation now opens setup for the new widget once the app is back in front; until then the widget shows its "Choose apps" state. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/MainActivity.kt | 14 +++++ .../devdrawer/appwidget/SaveWidgetWorker.kt | 49 ---------------- .../receivers/PinWidgetSuccessReceiver.kt | 25 ++------- .../devdrawer/receivers/PinnedWidgets.kt | 22 ++++++++ .../receivers/PinWidgetSuccessReceiverTest.kt | 56 +++++++++++++++++++ 5 files changed, 97 insertions(+), 69 deletions(-) delete mode 100644 app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt create mode 100644 app/src/main/java/de/psdev/devdrawer/receivers/PinnedWidgets.kt create mode 100644 app/src/test/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiverTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt index c66902cc..259310d1 100644 --- a/app/src/main/java/de/psdev/devdrawer/MainActivity.kt +++ b/app/src/main/java/de/psdev/devdrawer/MainActivity.kt @@ -10,9 +10,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import de.psdev.devdrawer.receivers.PinnedWidgets +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import mu.KLogging @@ -61,6 +64,17 @@ class MainActivity : BaseActivity() { } } + // A widget placed from the app's "Add widget" button gets its setup once the app is back in front. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(navigator) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + PinnedWidgets.pendingSetup.filterNotNull().collect { widgetId -> + PinnedWidgets.setupOpened(widgetId) + navigator.navigate(WidgetSetupRoute(widgetId)) + } + } + } + // Handle intents delivered while the app is already running. val pendingIntent = newIntent.value LaunchedEffect(pendingIntent) { diff --git a/app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt b/app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt deleted file mode 100644 index 51b4c598..00000000 --- a/app/src/main/java/de/psdev/devdrawer/appwidget/SaveWidgetWorker.kt +++ /dev/null @@ -1,49 +0,0 @@ -package de.psdev.devdrawer.appwidget - -import android.content.Context -import android.graphics.Color -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import de.psdev.devdrawer.database.DevDrawerDatabase -import de.psdev.devdrawer.database.Widget -import de.psdev.devdrawer.database.WidgetProfile -import de.psdev.devdrawer.receivers.UpdateReceiver -import mu.KLogging - -@HiltWorker -class SaveWidgetWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - private val database: DevDrawerDatabase -) : CoroutineWorker(appContext, params) { - - companion object : KLogging() { - const val ARG_WIDGET_ID = "widgetId" - const val INVALID_WIDGET_ID = -1 - } - - override suspend fun doWork(): Result { - val widgetId = inputData.getInt(ARG_WIDGET_ID, INVALID_WIDGET_ID) - check(widgetId != INVALID_WIDGET_ID) { "Invalid widget ID" } - val widgetDao = database.widgetDao() - val widgetProfileDao = database.widgetProfileDao() - val defaultWidgetProfile = widgetProfileDao.findAll().firstOrNull() - ?: WidgetProfile(name = "Default").also { - widgetProfileDao.insert(it) - } - - // Create entries in database - val widget = Widget( - id = widgetId, - name = "Widget $widgetId", - color = Color.BLACK, - profileId = defaultWidgetProfile.id - ) - widgetDao.insert(widget) - UpdateReceiver.send(applicationContext) - return Result.success() - } -} diff --git a/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt b/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt index 976a6adc..50d1c9cd 100644 --- a/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt +++ b/app/src/main/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiver.kt @@ -4,13 +4,9 @@ import android.appwidget.AppWidgetManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import de.psdev.devdrawer.appwidget.SaveWidgetWorker import mu.KLogging +/** Called by the launcher once the user confirms placing a widget requested from inside the app. */ class PinWidgetSuccessReceiver : BroadcastReceiver() { companion object : KLogging() { @@ -18,21 +14,10 @@ class PinWidgetSuccessReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { - logger.warn { "onReceive[context=$context, intent=$intent]" } - val widgetId = intent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID - ) + val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + logger.info { "Widget $widgetId pinned" } if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { - val inputData = Data.Builder().putInt(SaveWidgetWorker.ARG_WIDGET_ID, widgetId).build() - val request = OneTimeWorkRequestBuilder() - .setInputData(inputData) - .build() - WorkManager.getInstance(context).enqueueUniqueWork( - "SAVE_WIDGET_$widgetId", - ExistingWorkPolicy.REPLACE, - request - ) + PinnedWidgets.widgetPinned(widgetId) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/de/psdev/devdrawer/receivers/PinnedWidgets.kt b/app/src/main/java/de/psdev/devdrawer/receivers/PinnedWidgets.kt new file mode 100644 index 00000000..fe402219 --- /dev/null +++ b/app/src/main/java/de/psdev/devdrawer/receivers/PinnedWidgets.kt @@ -0,0 +1,22 @@ +package de.psdev.devdrawer.receivers + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A widget the launcher placed from the app's "Add widget" button. The launcher doesn't open setup for these, + * so the app opens it as soon as it is back in front. + */ +object PinnedWidgets { + private val pending = MutableStateFlow(null) + val pendingSetup: StateFlow = pending.asStateFlow() + + fun widgetPinned(widgetId: Int) { + pending.value = widgetId + } + + fun setupOpened(widgetId: Int) { + pending.compareAndSet(widgetId, null) + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiverTest.kt b/app/src/test/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiverTest.kt new file mode 100644 index 00000000..f97d7dc6 --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/receivers/PinWidgetSuccessReceiverTest.kt @@ -0,0 +1,56 @@ +package de.psdev.devdrawer.receivers + +import android.appwidget.AppWidgetManager +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class PinWidgetSuccessReceiverTest { + + @After + fun tearDown() { + PinnedWidgets.pendingSetup.value?.let(PinnedWidgets::setupOpened) + } + + @Test + fun `given the launcher placed a widget, when it confirms, then that widget waits for setup`() { + // Given + val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 12) + + // When + PinWidgetSuccessReceiver().onReceive(ApplicationProvider.getApplicationContext(), intent) + + // Then + assertEquals(12, PinnedWidgets.pendingSetup.value) + } + + @Test + fun `given a pinned widget waiting, when its setup opens, then it no longer waits`() { + // Given + PinnedWidgets.widgetPinned(12) + + // When + PinnedWidgets.setupOpened(12) + + // Then + assertNull(PinnedWidgets.pendingSetup.value) + } + + @Test + fun `given a confirmation without a widget id, when received, then nothing waits for setup`() { + // Given / When + PinWidgetSuccessReceiver().onReceive(ApplicationProvider.getApplicationContext(), Intent()) + + // Then + assertNull(PinnedWidgets.pendingSetup.value) + } +} From 7613f63d35594939fbfe25a6131d54af5c259c87 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 17:45:26 +0200 Subject: [PATCH 14/15] Save widget edits as they are made and drop "Delete widget" The widget editor lost unsaved changes on back without asking. Changes now save immediately; the name saves once typing pauses or the editor closes, and a blank name keeps the saved one. "Delete widget" only wiped the settings of a widget that stayed on the home screen. It is replaced by a hint on how to remove the widget there. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/DevDrawerHost.kt | 2 - .../de/psdev/devdrawer/analytics/Events.kt | 1 - .../widgets/ui/editor/WidgetEditorScreen.kt | 123 +++++++----------- .../ui/editor/WidgetEditorViewModel.kt | 51 ++++++-- .../ui/editor/WidgetEditorViewState.kt | 3 - app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../ui/editor/WidgetEditorScreenTest.kt | 38 ++++++ .../ui/editor/WidgetEditorViewModelTest.kt | 67 +++++----- 9 files changed, 155 insertions(+), 134 deletions(-) create mode 100644 app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreenTest.kt diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt index 9fa6c145..1eb723a1 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt @@ -54,8 +54,6 @@ fun DevDrawerHost( entry { key -> WidgetEditorScreen( id = key.id, - menuCallback = menuCallback, - onBack = { navigator.goBack() }, onEditWidgetProfile = { profile -> navigator.navigate(WidgetProfileEditorRoute(profile.id)) } diff --git a/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt b/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt index c3b663f2..99f5c2b1 100644 --- a/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt +++ b/app/src/main/java/de/psdev/devdrawer/analytics/Events.kt @@ -2,7 +2,6 @@ package de.psdev.devdrawer.analytics object Events { const val EVENT_WIDGET_OPEN_SETTINGS = "widget_open_settings" - const val WIDGET_DELETED = "widget_deleted" const val PROFILE_CREATED = "widget_profile_created" const val PROFILE_DELETED = "widget_profile_deleted" } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt index 2bad50f0..b8fa8711 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreen.kt @@ -2,9 +2,6 @@ package de.psdev.devdrawer.widgets.ui.editor import android.content.res.Configuration import android.os.Build -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -22,13 +19,10 @@ import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.outlined.Save import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,6 +30,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,14 +40,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import de.psdev.devdrawer.AppBarActionsProvider -import de.psdev.devdrawer.ProvideMenu import de.psdev.devdrawer.R import de.psdev.devdrawer.appwidget.AppInfo import de.psdev.devdrawer.appwidget.SortOrder @@ -72,24 +64,12 @@ fun WidgetEditorScreen( viewModel: WidgetEditorViewModel = hiltViewModel { it.create(id) }, - menuCallback: AppBarActionsProvider, - onBack: () -> Unit, onEditWidgetProfile: (WidgetProfile) -> Unit ) { val viewState by viewModel.state.collectAsStateWithLifecycle() - val persistedWidget = viewState.persistedWidget - ProvideMenu(menuCallback, persistedWidget) { - if (persistedWidget != null) { - IconButton(onClick = { - viewModel.deleteWidget(persistedWidget) - onBack() - }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.delete_widget) - ) - } - } + // Changes save as they are made; a name still being typed is saved when the editor closes. + DisposableEffect(viewModel) { + onDispose { viewModel.flushPendingChanges() } } WidgetEditor( viewState = viewState, @@ -97,8 +77,7 @@ fun WidgetEditorScreen( onHeaderColorSelected = viewModel::onHeaderColorSelected, onSortOrderSelected = viewModel::onSortOrderSelected, onEditWidgetProfile = onEditWidgetProfile, - onWidgetProfileSelected = viewModel::onWidgetProfileSelected, - onSaveChangesClick = viewModel::saveChanges + onWidgetProfileSelected = viewModel::onWidgetProfileSelected ) } @@ -110,8 +89,7 @@ fun WidgetEditor( onHeaderColorSelected: (WidgetHeaderColor) -> Unit = {}, onSortOrderSelected: (SortOrder?) -> Unit = {}, onEditWidgetProfile: (WidgetProfile) -> Unit = {}, - onWidgetProfileSelected: (WidgetProfile) -> Unit = {}, - onSaveChangesClick: () -> Unit = {} + onWidgetProfileSelected: (WidgetProfile) -> Unit = {} ) { val widget = viewState.editableWidget if (widget == null) { @@ -120,60 +98,47 @@ fun WidgetEditor( } return } - Box(modifier = modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - WidgetPreview( - widget = widget, - apps = viewState.previewApps, - appCount = viewState.previewAppCount - ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - singleLine = true, - value = widget.name, - onValueChange = onNameChange, - label = { Text(text = stringResource(id = R.string.name)) } - ) - HeaderColorPicker(selected = widget.headerColor, onSelected = onHeaderColorSelected) - SectionTitle(stringResource(R.string.editor_apps)) - Column(modifier = Modifier.selectableGroup()) { - viewState.profiles.forEach { option -> - ProfileRow( - option = option, - selected = option.profile.id == widget.profileId, - onSelected = { onWidgetProfileSelected(option.profile) }, - onEdit = { onEditWidgetProfile(option.profile) } - ) - } - } - SortOrderPreference( - current = widget.sortOrder, - defaultSortOrder = viewState.defaultSortOrder, - onSelected = onSortOrderSelected - ) - } - AnimatedVisibility( - visible = viewState.isDirty, - modifier = Modifier.align(Alignment.BottomEnd), - enter = fadeIn(), - exit = fadeOut() - ) { - FloatingActionButton( - onClick = onSaveChangesClick, - modifier = Modifier.padding(end = 16.dp, bottom = 16.dp) - ) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(id = R.string.save) + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + WidgetPreview( + widget = widget, + apps = viewState.previewApps, + appCount = viewState.previewAppCount + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + singleLine = true, + value = widget.name, + onValueChange = onNameChange, + label = { Text(text = stringResource(id = R.string.name)) } + ) + HeaderColorPicker(selected = widget.headerColor, onSelected = onHeaderColorSelected) + SectionTitle(stringResource(R.string.editor_apps)) + Column(modifier = Modifier.selectableGroup()) { + viewState.profiles.forEach { option -> + ProfileRow( + option = option, + selected = option.profile.id == widget.profileId, + onSelected = { onWidgetProfileSelected(option.profile) }, + onEdit = { onEditWidgetProfile(option.profile) } ) } } + SortOrderPreference( + current = widget.sortOrder, + defaultSortOrder = viewState.defaultSortOrder, + onSelected = onSortOrderSelected + ) + Text( + text = stringResource(R.string.widget_remove_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt index 8bfd3e41..d7118dfc 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModel.kt @@ -6,8 +6,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import de.psdev.devdrawer.analytics.Events -import de.psdev.devdrawer.analytics.TrackingService import de.psdev.devdrawer.apps.IAppsService import de.psdev.devdrawer.apps.comparator import de.psdev.devdrawer.apps.matching @@ -21,17 +19,23 @@ import de.psdev.devdrawer.profiles.IWidgetProfileRepository import de.psdev.devdrawer.profiles.ProfileWithAppCount import de.psdev.devdrawer.settings.ISortOrderSettings import de.psdev.devdrawer.widgets.IWidgetRepository +import kotlinx.coroutines.Job +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.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext @HiltViewModel(assistedFactory = WidgetEditorViewModel.Factory::class) class WidgetEditorViewModel @AssistedInject constructor( @@ -41,7 +45,6 @@ class WidgetEditorViewModel @AssistedInject constructor( packageFilterRepository: IPackageFilterRepository, private val appsService: IAppsService, private val sortOrderSettings: ISortOrderSettings, - private val trackingService: TrackingService ) : ViewModel() { @AssistedFactory @@ -98,39 +101,63 @@ class WidgetEditorViewModel @AssistedInject constructor( ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetEditorViewState.Empty) + /** Typing updates the field at once; the name is saved when typing pauses or the editor closes. */ fun onNameChanged(newName: String) { editableWidgetState.update { it?.copy(name = newName) } + pendingNameSave?.cancel() + pendingNameSave = viewModelScope.launch { + delay(NAME_SAVE_DELAY_MS) + persist() + } } fun onHeaderColorSelected(headerColor: WidgetHeaderColor) { editableWidgetState.update { it?.copy(headerColor = headerColor) } + persist() } /** Null resets the widget to the sort order from Settings. */ fun onSortOrderSelected(sortOrder: SortOrder?) { editableWidgetState.update { it?.copy(sortOrder = sortOrder) } + persist() } fun onWidgetProfileSelected(widgetProfile: WidgetProfile) { editableWidgetState.update { it?.copy(profileId = widgetProfile.id) } + persist() } - fun saveChanges() { - editableWidgetState.value?.let { - viewModelScope.launch { - widgetRepository.update(it) - } + /** Saves a name that is still waiting for typing to pause; called when the editor closes. */ + fun flushPendingChanges() { + if (pendingNameSave?.isActive == true) { + pendingNameSave?.cancel() + persist() } } - fun deleteWidget(widget: Widget) { + private var pendingNameSave: Job? = null + + private val saveMutex = Mutex() + + /** + * Writes the latest edit, keeping the saved name while the field is blank. Writes run one at a time so a + * quick second change can't be overtaken by the first, and they finish even if the editor closes. + */ + private fun persist() { viewModelScope.launch { - widgetRepository.delete(widget) - trackingService.trackAction(Events.WIDGET_DELETED) + withContext(NonCancellable) { + saveMutex.withLock { + val edited = editableWidgetState.value ?: return@withLock + val saved = widgetRepository.findById(widgetId) ?: return@withLock + val widget = edited.copy(name = edited.name.trim().ifEmpty { saved.name }) + if (widget != saved) widgetRepository.update(widget) + } + } } } private companion object { const val PREVIEW_APP_COUNT = 3 + const val NAME_SAVE_DELAY_MS = 500L } } diff --git a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt index 7cdf51bb..5f9a532f 100644 --- a/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt +++ b/app/src/main/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewState.kt @@ -18,9 +18,6 @@ data class WidgetEditorViewState( /** The Settings sort order, used while the widget has none of its own. */ val defaultSortOrder: SortOrder = SortOrder.LAST_UPDATED ) { - val isDirty: Boolean - get() = editableWidget != null && editableWidget != persistedWidget - companion object { val Empty = WidgetEditorViewState() } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 14a04d9a..b2d09522 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -118,7 +118,7 @@ Profil „%1$s“ · %2$s Keine App passt · Apps auswählen Widget zum Startbildschirm hinzufügen - Widget löschen + Um dieses Widget zu entfernen, halte es auf dem Startbildschirm gedrückt und ziehe es auf „Entfernen“. Widget-Vorschau Kopfzeilenfarbe Bernstein diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8fd90077..6cb47145 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,7 +158,7 @@ Profile “%1$s” · %2$s No apps match · Choose apps Add widget to home screen - Delete widget + To remove this widget, long-press it on your home screen and drag it to Remove. Widget preview Header colour Amber diff --git a/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreenTest.kt b/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreenTest.kt new file mode 100644 index 00000000..57798aaf --- /dev/null +++ b/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorScreenTest.kt @@ -0,0 +1,38 @@ +package de.psdev.devdrawer.widgets.ui.editor + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import de.psdev.devdrawer.database.Widget +import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +// SDK 36 requires Java 21; use SDK 33 which is compatible with the project's Java 17 toolchain. +@Config(sdk = [33]) +class WidgetEditorScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `given a renamed widget, when editing, then there is nothing to save and removing it is explained`() { + // Given + val saved = Widget(id = 1, name = "Work apps", color = 0, profileId = "p1") + + // When + composeTestRule.setContent { + DevDrawerTheme { + WidgetEditor(viewState = WidgetEditorViewState(persistedWidget = saved, editableWidget = saved.copy(name = "Renamed"))) + } + } + + // Then + composeTestRule.onNodeWithContentDescription("Save").assertDoesNotExist() + composeTestRule.onNodeWithText("To remove this widget, long-press it on your home screen and drag it to Remove.").assertExists() + } +} diff --git a/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt index 806c21f5..dffd89d2 100644 --- a/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/widgets/ui/editor/WidgetEditorViewModelTest.kt @@ -13,15 +13,13 @@ import de.psdev.devdrawer.fakes.FakePackageFilterRepository import de.psdev.devdrawer.fakes.FakeSortOrderSettings import de.psdev.devdrawer.fakes.FakeWidgetProfileRepository import de.psdev.devdrawer.fakes.FakeWidgetRepository -import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -51,8 +49,7 @@ class WidgetEditorViewModelTest { listOf(PackageFilter(type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "profile-1")) ), appsService = FakeAppsService(mapOf(zeta to "Zeta", alpha to "Alpha")), - sortOrderSettings = FakeSortOrderSettings(SortOrder.LAST_UPDATED), - trackingService = mockk(relaxed = true) + sortOrderSettings = FakeSortOrderSettings(SortOrder.LAST_UPDATED) ) @Test @@ -79,104 +76,104 @@ class WidgetEditorViewModelTest { assertEquals(listOf("My Profile" to 2, "Other" to 0), state.profiles.map { it.profile.name to it.appCount }) assertEquals(listOf("Zeta", "Alpha"), state.previewApps.map { it.name }) assertEquals(SortOrder.LAST_UPDATED, state.defaultSortOrder) - assertFalse(state.isDirty) } @Test - fun `given a loaded widget, when the name is changed, then editable name updates without affecting persisted name`() = runTest { + fun `given a loaded widget, when a header colour is picked, then it is saved right away`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle() // When - viewModel.onNameChanged("Renamed") + viewModel.onHeaderColorSelected(WidgetHeaderColor.DARK) advanceUntilIdle() // Then - assertEquals("Renamed", viewModel.state.value.editableWidget?.name) - assertEquals("Test Widget", viewModel.state.value.persistedWidget?.name) - assertTrue(viewModel.state.value.isDirty) + assertEquals(WidgetHeaderColor.DARK, viewModel.state.value.editableWidget?.headerColor) + assertEquals(WidgetHeaderColor.DARK, viewModel.state.value.persistedWidget?.headerColor) } @Test - fun `given a loaded widget, when a header colour is picked, then only the editable widget changes`() = runTest { + fun `given a loaded widget, when the name is typed, then it is saved once typing pauses`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle() // When - viewModel.onHeaderColorSelected(WidgetHeaderColor.DARK) - advanceUntilIdle() + viewModel.onNameChanged("Renamed") + advanceTimeBy(100) + runCurrent() // Then - assertEquals(WidgetHeaderColor.DARK, viewModel.state.value.editableWidget?.headerColor) - assertEquals(WidgetHeaderColor.AMBER, viewModel.state.value.persistedWidget?.headerColor) + assertEquals("Renamed", viewModel.state.value.editableWidget?.name) + assertEquals("Test Widget", viewModel.state.value.persistedWidget?.name) + advanceUntilIdle() + assertEquals("Renamed", viewModel.state.value.persistedWidget?.name) } @Test - fun `given a loaded widget, when its own sort order is picked, then the preview follows it`() = runTest { + fun `given a name still being typed, when leaving the editor, then it is saved immediately`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle() + viewModel.onNameChanged("Renamed") // When - viewModel.onSortOrderSelected(SortOrder.NAME) - advanceUntilIdle() + viewModel.flushPendingChanges() + runCurrent() // Then - assertEquals(SortOrder.NAME, viewModel.state.value.editableWidget?.sortOrder) - assertEquals(listOf("Alpha", "Zeta"), viewModel.state.value.previewApps.map { it.name }) + assertEquals("Renamed", viewModel.state.value.persistedWidget?.name) } @Test - fun `given a loaded widget, when another profile is selected, then the preview and selection follow it`() = runTest { + fun `given a cleared name, when typing pauses, then the widget keeps its saved name`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle() // When - viewModel.onWidgetProfileSelected(other) + viewModel.onNameChanged(" ") advanceUntilIdle() // Then - assertEquals("profile-2", viewModel.state.value.editableWidget?.profileId) - assertEquals("profile-1", viewModel.state.value.persistedWidget?.profileId) - assertEquals(emptyList(), viewModel.state.value.previewApps.map { it.name }) + assertEquals("Test Widget", viewModel.state.value.persistedWidget?.name) } @Test - fun `given a renamed widget, when changes are saved, then the persisted widget reflects the new name`() = runTest { + fun `given a loaded widget, when its own sort order is picked, then the preview follows it`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle() - viewModel.onNameChanged("Saved Name") // When - viewModel.saveChanges() + viewModel.onSortOrderSelected(SortOrder.NAME) advanceUntilIdle() // Then - assertEquals("Saved Name", viewModel.state.value.persistedWidget?.name) - assertFalse(viewModel.state.value.isDirty) + assertEquals(SortOrder.NAME, viewModel.state.value.editableWidget?.sortOrder) + assertEquals(listOf("Alpha", "Zeta"), viewModel.state.value.previewApps.map { it.name }) } @Test - fun `given a loaded widget, when the widget is deleted, then the persisted widget becomes null`() = runTest { + fun `given a loaded widget, when another profile is selected, then it is saved and the preview follows it`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle() // When - viewModel.deleteWidget(widget) + viewModel.onWidgetProfileSelected(other) advanceUntilIdle() // Then - assertNull(viewModel.state.value.persistedWidget) + assertEquals("profile-2", viewModel.state.value.editableWidget?.profileId) + assertEquals("profile-2", viewModel.state.value.persistedWidget?.profileId) + assertEquals(emptyList(), viewModel.state.value.previewApps.map { it.name }) } } From de8ed6e64b1154b06aa88322ab37b6aa58fb2100 Mon Sep 17 00:00:00 2001 From: Philip Porto Schiffer Date: Sat, 10 Oct 2026 17:47:45 +0200 Subject: [PATCH 15/15] Save profile edits as they are made The profile editor needed an explicit Save and asked "Discard changes?" on back, unlike the widget editor. Filters now save when added or removed, with an Undo snackbar for removals, and the name saves once typing pauses or the editor closes. The filter list and its matching apps are now derived from the same emission so they never disagree. The app bar actions plumbing had no users left and is removed. Co-Authored-By: Claude Opus 5.5 --- .../java/de/psdev/devdrawer/DevDrawerApp.kt | 15 ---- .../java/de/psdev/devdrawer/DevDrawerHost.kt | 5 +- .../java/de/psdev/devdrawer/TopBarMenu.kt | 19 ---- .../profiles/ui/editor/WidgetProfileEditor.kt | 83 +++++++---------- .../ui/editor/WidgetProfileEditorViewModel.kt | 90 +++++++++++-------- .../ui/editor/WidgetProfileEditorViewState.kt | 1 - app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- .../psdev/devdrawer/GermanTranslationTest.kt | 6 +- .../WidgetProfileEditorViewModelTest.kt | 64 +++++++++++-- 10 files changed, 149 insertions(+), 146 deletions(-) delete mode 100644 app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt index 9fc49b3a..3655dcec 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerApp.kt @@ -28,10 +28,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -83,15 +81,6 @@ fun DevDrawerApp( val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last() ?: navigationState.topLevelRoute - val (menuComposable, setMenu) = remember { - mutableStateOf(null) - } - - // Reset state on navigation change - LaunchedEffect(currentRoute) { - setMenu(null) - } - val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val navigationIcon: @Composable () -> Unit = if (currentRoute !in topLevelRoutes) { @@ -133,9 +122,6 @@ fun DevDrawerApp( navigationIcon = navigationIcon, title = { Text(text = stringResource(id = currentRoute.title)) - }, - actions = { - menuComposable?.invoke(this) } ) }, @@ -147,7 +133,6 @@ fun DevDrawerApp( DevDrawerHost( navigationState = navigationState, navigator = navigator, - menuCallback = setMenu, modifier = Modifier.weight(1f), onWidgetSetupDone = onWidgetSetupDone, onWidgetSetupBack = onWidgetSetupBack diff --git a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt index 1eb723a1..046ef6e7 100644 --- a/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt +++ b/app/src/main/java/de/psdev/devdrawer/DevDrawerHost.kt @@ -18,7 +18,6 @@ import de.psdev.devdrawer.widgets.ui.setup.WidgetSetupScreen fun DevDrawerHost( navigationState: NavigationState, navigator: Navigator, - menuCallback: AppBarActionsProvider, modifier: Modifier = Modifier, onWidgetSetupDone: (Widget) -> Unit = { navigator.goBack() }, onWidgetSetupBack: (Int) -> Unit = { navigator.goBack() } @@ -69,9 +68,7 @@ fun DevDrawerHost( } entry { key -> WidgetProfileEditor( - profileId = key.id, - menuCallback = menuCallback, - onBack = { navigator.goBack() } + profileId = key.id ) } } diff --git a/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt b/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt deleted file mode 100644 index eeea2451..00000000 --- a/app/src/main/java/de/psdev/devdrawer/TopBarMenu.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.psdev.devdrawer - -import androidx.compose.foundation.layout.RowScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect - -typealias AppBarActions = @Composable RowScope.() -> Unit -typealias AppBarActionsProvider = (AppBarActions?) -> Unit - -@Composable -fun ProvideMenu( - actionsProvider: AppBarActionsProvider, - updateKey: Any? = null, - actions: AppBarActions -) { - LaunchedEffect(key1 = updateKey) { - actionsProvider(actions) - } -} 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 18d9b5ff..4bb95c2e 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 @@ -1,7 +1,6 @@ package de.psdev.devdrawer.profiles.ui.editor import android.content.res.Configuration -import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,8 +15,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton @@ -27,18 +24,25 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -47,21 +51,18 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import de.psdev.devdrawer.AppBarActionsProvider -import de.psdev.devdrawer.ProvideMenu import de.psdev.devdrawer.R import de.psdev.devdrawer.database.FilterType import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.ui.theme.DevDrawerTheme +import kotlinx.coroutines.launch private const val COLLAPSED_APP_COUNT = 5 @Composable fun WidgetProfileEditor( profileId: String, - menuCallback: AppBarActionsProvider, - onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: WidgetProfileEditorViewModel = hiltViewModel( creationCallback = { factory: WidgetProfileEditorViewModel.Factory -> @@ -71,33 +72,38 @@ fun WidgetProfileEditor( ) { val viewState by viewModel.state.collectAsState(initial = WidgetProfileEditorViewState.Empty) var currentDialog by remember { mutableStateOf(WidgetProfileEditorDialogs.None) } - val hasChanges = viewState.isDirty + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val resources = LocalResources.current - BackHandler { - if (hasChanges) { - currentDialog = WidgetProfileEditorDialogs.DiscardChanges - } else { - onBack() - } + // Changes save as they are made; a name still being typed is saved when the editor closes. + DisposableEffect(viewModel) { + onDispose { viewModel.flushPendingChanges() } } - ProvideMenu(menuCallback, viewState) { - if (hasChanges) { - IconButton(onClick = { viewModel.saveChanges(viewState) }) { - Icon(imageVector = Icons.Outlined.Save, contentDescription = stringResource(id = R.string.save)) + Box(modifier = modifier.fillMaxSize()) { + WidgetProfileEditor( + viewState = viewState, + modifier = Modifier.fillMaxSize(), + onNameChange = viewModel::onNameChanged, + onAddFilterClick = { currentDialog = WidgetProfileEditorDialogs.AddFilter }, + onPackageFilterClick = { currentDialog = WidgetProfileEditorDialogs.PackageFilterInfo(it) }, + onRemoveFilterClick = { filter -> + viewModel.deleteFilter(filter) + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + message = resources.getString(R.string.filter_removed), + actionLabel = resources.getString(R.string.undo), + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) viewModel.restoreFilter(filter) + } } - } + ) + SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) } - WidgetProfileEditor( - viewState = viewState, - modifier = modifier.fillMaxSize(), - onNameChange = viewModel::onNameChanged, - onAddFilterClick = { currentDialog = WidgetProfileEditorDialogs.AddFilter }, - onPackageFilterClick = { currentDialog = WidgetProfileEditorDialogs.PackageFilterInfo(it) }, - onRemoveFilterClick = viewModel::deleteFilter - ) - when (val dialog = currentDialog) { WidgetProfileEditorDialogs.None -> Unit WidgetProfileEditorDialogs.AddFilter -> AddFilterSheet( @@ -144,25 +150,6 @@ fun WidgetProfileEditor( currentDialog = WidgetProfileEditorDialogs.None } ) - WidgetProfileEditorDialogs.DiscardChanges -> AlertDialog( - onDismissRequest = { currentDialog = WidgetProfileEditorDialogs.None }, - title = { Text(text = stringResource(R.string.discard_changes)) }, - text = { Text(text = stringResource(R.string.discard_changes_confirmation)) }, - confirmButton = { - TextButton(onClick = { - currentDialog = WidgetProfileEditorDialogs.None - viewModel.clearLocalChanges() - onBack() - }) { - Text(text = stringResource(R.string.discard)) - } - }, - dismissButton = { - TextButton(onClick = { currentDialog = WidgetProfileEditorDialogs.None }) { - Text(text = stringResource(R.string.cancel)) - } - } - ) } } @@ -180,8 +167,6 @@ private sealed class WidgetProfileEditorDialogs { data class PackageFilterInfo( val packageFilter: PackageFilter ) : WidgetProfileEditorDialogs() - - data object DiscardChanges : WidgetProfileEditorDialogs() } @Composable 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 18ec0895..e7640692 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 @@ -9,16 +9,21 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.psdev.devdrawer.apps.IAppsService import de.psdev.devdrawer.apps.comparator import de.psdev.devdrawer.apps.matching +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.PackageFilter import de.psdev.devdrawer.profiles.IPackageFilterRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @HiltViewModel(assistedFactory = WidgetProfileEditorViewModel.Factory::class) class WidgetProfileEditorViewModel @AssistedInject constructor( @@ -33,8 +38,9 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( fun create(profileId: String): WidgetProfileEditorViewModel } + /** The name as typed; saved once typing pauses or the editor closes. */ private val widgetNameState: MutableStateFlow = MutableStateFlow(null) - private val packageFiltersState: MutableStateFlow?> = MutableStateFlow(null) + private var pendingNameSave: Job? = null private val dbFiltersFlow = database.packageFilterDao().findAllByProfileFlow(profileId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) @@ -47,32 +53,21 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( } } - /** Matching apps and per-filter counts; only recomputed when the edited filters or the installed apps change. */ - private val matches = combine(dbFiltersFlow, packageFiltersState, installedPackages) { dbFilters, inMemoryFilters, packages -> - val filters = inMemoryFilters ?: dbFilters + /** The filters with their matching apps and per-filter counts, computed together so they never disagree. */ + private val matches = combine(dbFiltersFlow, installedPackages) { filters, packages -> val apps = appsService.appInfos(packages.matching(filters)).sortedWith(SortOrder.NAME.comparator()) - apps to filters.associate { it.id to packages.matching(listOf(it)).size } + FilterMatches(filters, apps, filters.associate { it.id to packages.matching(listOf(it)).size }) } val state = combine( database.widgetProfileDao().widgetProfileWithIdObservable(profileId), - dbFiltersFlow, widgetNameState, - packageFiltersState, matches - ) { widgetProfile, dbPackageFilters, name, inMemoryFilters, (matchingApps, filterAppCounts) -> - val currentFilters = inMemoryFilters ?: dbPackageFilters - val currentName = name ?: widgetProfile?.name.orEmpty() - - val nameChanged = name != null && name != widgetProfile?.name - // Use a set-based comparison to avoid issues with order or duplicate objects with same content - val filtersChanged = inMemoryFilters != null && inMemoryFilters.toSet() != dbPackageFilters.toSet() - + ) { widgetProfile, name, (filters, matchingApps, filterAppCounts) -> WidgetProfileEditorViewState( widgetProfile = widgetProfile, - widgetName = currentName, - packageFilters = currentFilters, - isDirty = nameChanged || filtersChanged, + widgetName = name ?: widgetProfile?.name.orEmpty(), + packageFilters = filters, matchingApps = matchingApps, filterAppCounts = filterAppCounts ) @@ -80,38 +75,55 @@ class WidgetProfileEditorViewModel @AssistedInject constructor( fun onNameChanged(name: String) { widgetNameState.value = name + pendingNameSave?.cancel() + pendingNameSave = viewModelScope.launch { + delay(NAME_SAVE_DELAY_MS) + saveName(name) + } } - fun saveChanges(viewState: WidgetProfileEditorViewState) { - viewModelScope.launch { - val widgetProfile = viewState.widgetProfile ?: return@launch - val newName = viewState.widgetName ?: return@launch - packageFilterRepository.saveProfile(widgetProfile.copy(name = newName), viewState.packageFilters) - // Reset local state after save - clearLocalChanges() + /** Saves a name that is still waiting for typing to pause; called when the editor closes. */ + fun flushPendingChanges() { + val name = widgetNameState.value + if (pendingNameSave?.isActive == true && name != null) { + pendingNameSave?.cancel() + saveName(name) } } fun addPackageFilter(packageFilter: PackageFilter) { - viewModelScope.launch { - val dbFilters = dbFiltersFlow.value - val currentFilters = packageFiltersState.value ?: dbFilters - val newFilters = currentFilters + packageFilter - packageFiltersState.value = if (newFilters.toSet() == dbFilters.toSet()) null else newFilters - } + write { packageFilterRepository.save(packageFilter) } } fun deleteFilter(packageFilter: PackageFilter) { - viewModelScope.launch { - val dbFilters = dbFiltersFlow.value - val currentFilters = packageFiltersState.value ?: dbFilters - val newFilters = currentFilters.filter { it.id != packageFilter.id } - packageFiltersState.value = if (newFilters.toSet() == dbFilters.toSet()) null else newFilters + write { packageFilterRepository.delete(packageFilter) } + } + + /** Undoes [deleteFilter]. */ + fun restoreFilter(packageFilter: PackageFilter) = addPackageFilter(packageFilter) + + /** A blank name keeps the saved one. */ + private fun saveName(name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + write { + val profile = database.widgetProfileDao().findById(profileId) ?: return@write + if (profile.name != trimmed) database.widgetProfileDao().updateWithTimestamp(profile.copy(name = trimmed)) } } - fun clearLocalChanges() { - widgetNameState.value = null - packageFiltersState.value = null + /** Edits are written straight away and finish even if the editor closes meanwhile. */ + private fun write(block: suspend () -> Unit) { + viewModelScope.launch { withContext(NonCancellable) { block() } } + } + + private data class FilterMatches( + val filters: List, + val apps: List, + val appCounts: Map + ) + + private companion object { + const val NAME_SAVE_DELAY_MS = 500L } } 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 421bf415..149ceb46 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 @@ -10,7 +10,6 @@ data class WidgetProfileEditorViewState( val widgetProfile: WidgetProfile? = null, val widgetName: String? = null, val packageFilters: List = emptyList(), - val isDirty: Boolean = false, /** 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. */ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b2d09522..671c5e9b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -5,7 +5,6 @@ App info Profile Widgets - Speichern Keine Profile Neu Name @@ -16,7 +15,6 @@ Schließen Information App Signatur - Du kannst Profile bearbeiten, in dem du den Eintrag lange drückst Löschen Paketname App Icon @@ -51,9 +49,6 @@ Keine Apps verfügbar oder alle bereits als Filter hinzugefügt Mehr System-Apps einbeziehen - Änderungen verwerfen? - Du hast ungespeicherte Änderungen. Möchtest du sie verwerfen? - Verwerfen Zuletzt aktualisiert @@ -145,6 +140,7 @@ Duplizieren Profil „%1$s“ gelöscht Rückgängig + Filter entfernt %d Filter %d Filter diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6cb47145..53dcc261 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,7 +45,6 @@ Profiles Widgets - Save No profiles New Name @@ -56,7 +55,6 @@ Close Info App signature - You can edit profiles by long-clicking an entry Delete Package name App icon @@ -102,9 +100,6 @@ Include system apps - Discard changes? - You have unsaved changes. Do you want to discard them? - Discard Select option @@ -185,6 +180,7 @@ Duplicate Profile “%1$s” deleted Undo + Filter removed %d filter %d filters diff --git a/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt b/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt index 25ce2bf9..8ac4a1b7 100644 --- a/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/GermanTranslationTest.kt @@ -25,11 +25,11 @@ class GermanTranslationTest { } @Test - fun `given a German locale, when reading discard, then it is translated`() { + fun `given a German locale, when reading undo, then it is translated`() { // Given / When - val result = context.getString(R.string.discard) + val result = context.getString(R.string.undo) // Then - assertEquals("Verwerfen", result) + assertEquals("Rückgängig", result) } } diff --git a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt index c32783ea..beffbe1e 100644 --- a/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt +++ b/app/src/test/java/de/psdev/devdrawer/profiles/ui/editor/WidgetProfileEditorViewModelTest.kt @@ -8,11 +8,12 @@ import de.psdev.devdrawer.database.FilterType import de.psdev.devdrawer.database.PackageFilter import de.psdev.devdrawer.database.WidgetProfile import de.psdev.devdrawer.fakes.FakeAppsService -import de.psdev.devdrawer.fakes.FakePackageFilterRepository +import de.psdev.devdrawer.profiles.PackageFilterRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -63,7 +64,7 @@ class WidgetProfileEditorViewModelTest { private fun createViewModel() = WidgetProfileEditorViewModel( profileId = "p1", database = database, - packageFilterRepository = FakePackageFilterRepository(), + packageFilterRepository = PackageFilterRepository(RuntimeEnvironment.getApplication(), database), appsService = appsService ) @@ -81,7 +82,7 @@ class WidgetProfileEditorViewModelTest { } @Test - fun `given an added filter not yet saved, when the state updates, then matching apps include its apps`() = runTest { + fun `given a new filter, when added, then it is saved and its apps match`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } @@ -94,10 +95,11 @@ class WidgetProfileEditorViewModelTest { // Then assertEquals(listOf("Client", "DevDrawer2", "Other"), state.matchingApps.map { it.name }) assertEquals(mapOf("f1" to 2, "f2" to 1), state.filterAppCounts) + assertEquals(setOf("f1", "f2"), database.packageFilterDao().findAllByProfile("p1").map { it.id }.toSet()) } @Test - fun `given a removed filter not yet saved, when the state updates, then its apps disappear`() = runTest { + fun `given a filter, when removed, then it is deleted and can be restored`() = runTest { // Given val viewModel = createViewModel() backgroundScope.launch { viewModel.state.collect {} } @@ -105,9 +107,59 @@ class WidgetProfileEditorViewModelTest { // When viewModel.deleteFilter(signatureFilter) - val state = viewModel.state.first { it.packageFilters.isEmpty() } + val removed = viewModel.state.first { it.packageFilters.isEmpty() } // Then - assertEquals(emptyList(), state.matchingApps.map { it.name }) + assertEquals(emptyList(), removed.matchingApps.map { it.name }) + assertEquals(emptyList(), database.packageFilterDao().findAllByProfile("p1")) + viewModel.restoreFilter(signatureFilter) + viewModel.state.first { it.packageFilters == listOf(signatureFilter) } + } + + @Test + fun `given a new name, when typing pauses, then the profile is renamed`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + viewModel.state.first { it.widgetProfile != null } + + // When + viewModel.onNameChanged("Work apps") + + // Then + assertEquals("Work apps", viewModel.state.first { it.widgetName == "Work apps" }.widgetName) + viewModel.state.first { it.widgetProfile?.name == "Work apps" } + } + + @Test + fun `given a name still being typed, when leaving the editor, then it is saved immediately`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + viewModel.state.first { it.widgetProfile != null } + viewModel.onNameChanged("Work apps") + + // When + viewModel.flushPendingChanges() + + // Then + viewModel.state.first { it.widgetProfile?.name == "Work apps" } + } + + @Test + fun `given a cleared name, when typing pauses, then the profile keeps its saved name`() = runTest { + // Given + val viewModel = createViewModel() + backgroundScope.launch { viewModel.state.collect {} } + viewModel.state.first { it.widgetProfile != null } + + // When + viewModel.onNameChanged(" ") + advanceUntilIdle() + viewModel.addPackageFilter(PackageFilter(id = "f2", type = FilterType.PACKAGE_NAME, filter = "com.example.*", profileId = "p1")) + viewModel.state.first { it.packageFilters.size == 2 } + + // Then + assertEquals("Mine", database.widgetProfileDao().findById("p1")?.name) } }