diff --git a/.github/workflows/crowdin-actions.yml b/.github/workflows/crowdin-actions.yml index bdc008c7c9..e0f46f3c7f 100644 --- a/.github/workflows/crowdin-actions.yml +++ b/.github/workflows/crowdin-actions.yml @@ -20,6 +20,7 @@ jobs: - name: crowdin action uses: crowdin/github-action@v2 + if: github.event.repository.fork == false with: upload_sources: true upload_translations: false diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 754ebd0179..7bef735539 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -96,6 +96,7 @@ jobs: ruby-version: '3.3' - name: Create debug keystore + if: github.event.repository.fork == false env: CI_KEYSTORE: ${{ secrets.CI_KEYSTORE }} run: | @@ -123,6 +124,7 @@ jobs: - name: Upload to Discord uses: sinshutu/upload-to-discord@v2.0.0 + if: github.event.repository.fork == false env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: @@ -130,7 +132,7 @@ jobs: - name: Report build status to Discord uses: sarisia/actions-status-discord@v1 - if: failure() + if: github.event.repository.fork == false && failure() with: title: "Build apk" webhook: ${{ secrets.DISCORD_BUILD_STATUS_WEBHOOK }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 13dc72f83a..3ed837fe12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,36 @@ -## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) - -#### TO BE RELEASED +## [3.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.3) _See the changes from previous 3.0 Beta releases as well._ +#### 1 April 2025 + +This is not an April Fool's joke ;) + +## Added +- #320 🗂️ Key map groups! You can now sort key maps into groups and share constraints across all the key maps in the group. +- #1586 🎨 Customise floating button border and background opacity. +- #1276 Use key event scan code as fallback if the key code is unrecognized. +- Make it clearer that the instructions need to be read for the assistant trigger. + +## Changed + +- Turn off flashlight when using decrease brightness action. +- Animate floating buttons in and out. + +## Bug fixes + +- Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. +- Do not show floating buttons on the always-on display or when the display is "off". +- Prompt to unlock device when tapping "Go back" on the floating menu. +- #1596 Do not show the option for front flashlight if the device does not have one. +- #1598 Do not allow changing flashlight brightness on devices that do not support it. +- Omit "Back" from Back flashlight actions and constraints since most devices only have a back flashlight anyway. +- Do not ask for which flashlight to use in constraints if the device only has one + +## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) + +#### 27 March 2025 + ## Added - #1560 Action to change flashlight brightness and also set a custom brightness when enabling the flashlight. diff --git a/app/build.gradle b/app/build.gradle index 82613d7e1e..117a9d541a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,11 +98,14 @@ android { dimension "pro" File file = rootProject.file("local.properties") + String keyName = "REVENUECAT_API_KEY" if (file.exists()) { def localProperties = new Properties() localProperties.load(new FileInputStream(file)) - buildConfigField("String", "REVENUECAT_API_KEY", localProperties["REVENUECAT_API_KEY"]) + if (localProperties.containsKey(keyName)) { + buildConfigField("String", keyName, localProperties[keyName]) + } } } } diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json new file mode 100644 index 0000000000..e4da7a781f --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json @@ -0,0 +1,324 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "eb7c2d3cb69e3eb4170ee2a3227c4805", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `folder_name` TEXT, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderName", + "columnName": "folder_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "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, 'eb7c2d3cb69e3eb4170ee2a3227c4805')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/16.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/16.json new file mode 100644 index 0000000000..bdd3ecf6b1 --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/16.json @@ -0,0 +1,402 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "9d6274fe030cd7a35ed4138fcc163383", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_groups_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_groups_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "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, '9d6274fe030cd7a35ed4138fcc163383')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json new file mode 100644 index 0000000000..8946e01ad8 --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json @@ -0,0 +1,408 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "88c90aad1691900805c9d1f229f42d71", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, `last_opened_date` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_groups_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_groups_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "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, '88c90aad1691900805c9d1f229f42d71')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json new file mode 100644 index 0000000000..0fb4041f1a --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json @@ -0,0 +1,398 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "d6bb60215344b4b6c1a49f72576b6535", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, `last_opened_date` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "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, 'd6bb60215344b4b6c1a49f72576b6535')" + ] + } +} \ No newline at end of file diff --git a/app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt b/app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt new file mode 100644 index 0000000000..aca43b669c --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt @@ -0,0 +1,18 @@ +package io.github.sds100.keymapper.home + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.navigation.NavHostController +import io.github.sds100.keymapper.floating.ListFloatingLayoutsViewModel + +@Composable +fun HomeFloatingLayoutsScreen( + modifier: Modifier = Modifier, + viewModel: ListFloatingLayoutsViewModel, + navController: NavHostController, + snackbarState: SnackbarHostState, + fabBottomPadding: Dp, +) { +} diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 0bbfd2565f..1b0703f604 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -15,7 +15,6 @@ import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.FingerprintGestureType -import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyEventDetectionSource @@ -73,324 +72,318 @@ class KeyMapController( /** * A cached copy of the keymaps in the database */ - private var keyMapList: List = listOf() - set(value) { - actionMap.clear() - - // If there are no keymaps with actions then keys don't need to be detected. - if (!value.any { it.actionList.isNotEmpty() }) { - field = value - detectKeyMaps = false - return - } - - if (value.all { !it.isEnabled }) { - detectKeyMaps = false - return - } + private fun loadKeyMaps(value: List) { + actionMap.clear() - if (value.isEmpty()) { - detectKeyMaps = false - } else { - detectKeyMaps = true - - val longPressSequenceTriggerKeys = mutableListOf() + // If there are no keymaps with actions then keys don't need to be detected. + if (!value.any { it.keyMap.actionList.isNotEmpty() }) { + detectKeyMaps = false + return + } - val doublePressKeys = mutableListOf() + if (value.all { !it.keyMap.isEnabled }) { + detectKeyMaps = false + return + } - setActionMapAndOptions(value.flatMap { it.actionList }.toSet()) + if (value.isEmpty()) { + detectKeyMaps = false + } else { + detectKeyMaps = true - val triggers = mutableListOf() - val sequenceTriggers = mutableListOf() - val parallelTriggers = mutableListOf() + val longPressSequenceTriggerKeys = mutableListOf() - val triggerActions = mutableListOf() - val triggerConstraints = mutableListOf() + val doublePressKeys = mutableListOf() - val sequenceTriggerActionPerformers = - mutableMapOf() - val parallelTriggerActionPerformers = - mutableMapOf() - val parallelTriggerModifierKeyIndices = mutableListOf>() - val triggerKeysThatSendRepeatedKeyEvents = mutableSetOf() + setActionMapAndOptions(value.flatMap { it.keyMap.actionList }.toSet()) - // Only process key maps that can be triggered - val validKeyMaps = value.filter { - it.actionList.isNotEmpty() && it.isEnabled - } + val triggers = mutableListOf() + val sequenceTriggers = mutableListOf() + val parallelTriggers = mutableListOf() - for ((triggerIndex, keyMap) in validKeyMaps.withIndex()) { + val triggerActions = mutableListOf() + val triggerConstraints = mutableListOf>() - // TRIGGER STUFF - keyMap.trigger.keys - .filter { it is KeyCodeTriggerKey || it is FingerprintTriggerKey } - .forEachIndexed { keyIndex, key -> - if (key is KeyCodeTriggerKey && key.detectionSource == KeyEventDetectionSource.INPUT_METHOD && key.consumeEvent) { - triggerKeysThatSendRepeatedKeyEvents.add(key) - } + val sequenceTriggerActionPerformers = + mutableMapOf() + val parallelTriggerActionPerformers = + mutableMapOf() + val parallelTriggerModifierKeyIndices = mutableListOf>() + val triggerKeysThatSendRepeatedKeyEvents = mutableSetOf() - if (keyMap.trigger.mode == TriggerMode.Sequence && - key.clickType == ClickType.LONG_PRESS && - key is KeyCodeTriggerKey - ) { + // Only process key maps that can be triggered + val validKeyMaps = value.filter { + it.keyMap.actionList.isNotEmpty() && it.keyMap.isEnabled + } - if (keyMap.trigger.keys.size > 1) { - longPressSequenceTriggerKeys.add(key) - } - } + for ((triggerIndex, model) in validKeyMaps.withIndex()) { + val keyMap = model.keyMap + // TRIGGER STUFF + keyMap.trigger.keys + .filter { it is KeyCodeTriggerKey || it is FingerprintTriggerKey } + .forEachIndexed { keyIndex, key -> + if (key is KeyCodeTriggerKey && key.detectionSource == KeyEventDetectionSource.INPUT_METHOD && key.consumeEvent) { + triggerKeysThatSendRepeatedKeyEvents.add(key) + } - if (keyMap.trigger.mode !is TriggerMode.Parallel && - key.clickType == ClickType.DOUBLE_PRESS - ) { - doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) + if (keyMap.trigger.mode == TriggerMode.Sequence && + key.clickType == ClickType.LONG_PRESS && + key is KeyCodeTriggerKey + ) { + if (keyMap.trigger.keys.size > 1) { + longPressSequenceTriggerKeys.add(key) } + } - when (key) { - is KeyCodeTriggerKey -> when (key.device) { - TriggerKeyDevice.Internal -> { - detectInternalEvents = true - } + if (keyMap.trigger.mode !is TriggerMode.Parallel && + key.clickType == ClickType.DOUBLE_PRESS + ) { + doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) + } - TriggerKeyDevice.Any -> { - detectInternalEvents = true - detectExternalEvents = true - } + when (key) { + is KeyCodeTriggerKey -> when (key.device) { + TriggerKeyDevice.Internal -> { + detectInternalEvents = true + } - is TriggerKeyDevice.External -> { - detectExternalEvents = true - } + TriggerKeyDevice.Any -> { + detectInternalEvents = true + detectExternalEvents = true } - else -> {} + is TriggerKeyDevice.External -> { + detectExternalEvents = true + } } - } - - val encodedActionList = encodeActionList(keyMap.actionList) - if (keyMap.actionList.any { - it.data is ActionData.InputKeyEvent && - isModifierKey( - it.data.keyCode, - ) + else -> {} } - ) { - modifierKeyEventActions = true } - if (keyMap.actionList.any { - it.data is ActionData.InputKeyEvent && - !isModifierKey( - it.data.keyCode, - ) - } - ) { - notModifierKeyEventActions = true - } + val encodedActionList = encodeActionList(keyMap.actionList) - triggers.add(keyMap.trigger) - triggerActions.add(encodedActionList) - triggerConstraints.add(keyMap.constraintState) - - if (performActionOnDown(keyMap.trigger)) { - parallelTriggers.add(triggerIndex) - parallelTriggerActionPerformers[triggerIndex] = - ParallelTriggerActionPerformer( - coroutineScope, - performActionsUseCase, - keyMap.actionList, + if (keyMap.actionList.any { + it.data is ActionData.InputKeyEvent && + isModifierKey( + it.data.keyCode, ) - } else { - sequenceTriggers.add(triggerIndex) - sequenceTriggerActionPerformers[triggerIndex] = - SequenceTriggerActionPerformer( - coroutineScope, - performActionsUseCase, - keyMap.actionList, + } + ) { + modifierKeyEventActions = true + } + + if (keyMap.actionList.any { + it.data is ActionData.InputKeyEvent && + !isModifierKey( + it.data.keyCode, ) } + ) { + notModifierKeyEventActions = true } - val sequenceTriggersOverlappingSequenceTriggers = - MutableList(triggers.size) { mutableSetOf() } + triggers.add(keyMap.trigger) + triggerActions.add(encodedActionList) - for (triggerIndex in sequenceTriggers) { - val trigger = triggers[triggerIndex] + val constraintStates = + model.groupConstraintStates.plus(keyMap.constraintState).toTypedArray() + triggerConstraints.add(constraintStates) - otherTriggerLoop@ for (otherTriggerIndex in sequenceTriggers) { - val otherTrigger = triggers[otherTriggerIndex] + if (performActionOnDown(keyMap.trigger)) { + parallelTriggers.add(triggerIndex) + parallelTriggerActionPerformers[triggerIndex] = + ParallelTriggerActionPerformer( + coroutineScope, + performActionsUseCase, + keyMap.actionList, + ) + } else { + sequenceTriggers.add(triggerIndex) + sequenceTriggerActionPerformers[triggerIndex] = + SequenceTriggerActionPerformer( + coroutineScope, + performActionsUseCase, + keyMap.actionList, + ) + } + } - for ((keyIndex, key) in trigger.keys.withIndex()) { - var lastMatchedIndex: Int? = null + val sequenceTriggersOverlappingSequenceTriggers = + MutableList(triggers.size) { mutableSetOf() } - for ((otherIndex, otherKey) in otherTrigger.keys.withIndex()) { - if (key.matchesWithOtherKey(otherKey)) { + for (triggerIndex in sequenceTriggers) { + val trigger = triggers[triggerIndex] - // the other trigger doesn't overlap after the first element - if (otherIndex == 0) continue@otherTriggerLoop + otherTriggerLoop@ for (otherTriggerIndex in sequenceTriggers) { + val otherTrigger = triggers[otherTriggerIndex] - // make sure the overlap retains the order of the trigger - if (lastMatchedIndex != null && lastMatchedIndex != otherIndex - 1) { - continue@otherTriggerLoop - } + for ((keyIndex, key) in trigger.keys.withIndex()) { + var lastMatchedIndex: Int? = null - if (keyIndex == trigger.keys.lastIndex) { - sequenceTriggersOverlappingSequenceTriggers[triggerIndex].add( - otherTriggerIndex, - ) - } + for ((otherIndex, otherKey) in otherTrigger.keys.withIndex()) { + if (key.matchesWithOtherKey(otherKey)) { + // the other trigger doesn't overlap after the first element + if (otherIndex == 0) continue@otherTriggerLoop + + // make sure the overlap retains the order of the trigger + if (lastMatchedIndex != null && lastMatchedIndex != otherIndex - 1) { + continue@otherTriggerLoop + } - lastMatchedIndex = otherIndex + if (keyIndex == trigger.keys.lastIndex) { + sequenceTriggersOverlappingSequenceTriggers[triggerIndex].add( + otherTriggerIndex, + ) } + + lastMatchedIndex = otherIndex } } } } + } - val sequenceTriggersOverlappingParallelTriggers = - MutableList(triggers.size) { mutableSetOf() } - - for (triggerIndex in parallelTriggers) { - val parallelTrigger = triggers[triggerIndex] - - otherTriggerLoop@ for (otherTriggerIndex in sequenceTriggers) { - val otherTrigger = triggers[otherTriggerIndex] - - // Don't compare a trigger to itself - if (triggerIndex == otherTriggerIndex) { - continue@otherTriggerLoop - } - - for ((keyIndex, key) in parallelTrigger.keys.withIndex()) { - var lastMatchedIndex: Int? = null + val sequenceTriggersOverlappingParallelTriggers = + MutableList(triggers.size) { mutableSetOf() } - for ((otherKeyIndex, otherKey) in otherTrigger.keys.withIndex()) { + for (triggerIndex in parallelTriggers) { + val parallelTrigger = triggers[triggerIndex] - if (key.matchesWithOtherKey(otherKey)) { + otherTriggerLoop@ for (otherTriggerIndex in sequenceTriggers) { + val otherTrigger = triggers[otherTriggerIndex] - // make sure the overlap retains the order of the trigger - if (lastMatchedIndex != null && lastMatchedIndex != otherKeyIndex - 1) { - continue@otherTriggerLoop - } + // Don't compare a trigger to itself + if (triggerIndex == otherTriggerIndex) { + continue@otherTriggerLoop + } - if (keyIndex == parallelTrigger.keys.lastIndex) { - sequenceTriggersOverlappingParallelTriggers[triggerIndex].add( - otherTriggerIndex, - ) - } + for ((keyIndex, key) in parallelTrigger.keys.withIndex()) { + var lastMatchedIndex: Int? = null - lastMatchedIndex = otherKeyIndex + for ((otherKeyIndex, otherKey) in otherTrigger.keys.withIndex()) { + if (key.matchesWithOtherKey(otherKey)) { + // make sure the overlap retains the order of the trigger + if (lastMatchedIndex != null && lastMatchedIndex != otherKeyIndex - 1) { + continue@otherTriggerLoop } - // if there were no matching keys in the other trigger then skip this trigger - if (lastMatchedIndex == null && otherKeyIndex == otherTrigger.keys.lastIndex) { - continue@otherTriggerLoop + if (keyIndex == parallelTrigger.keys.lastIndex) { + sequenceTriggersOverlappingParallelTriggers[triggerIndex].add( + otherTriggerIndex, + ) } + + lastMatchedIndex = otherKeyIndex + } + + // if there were no matching keys in the other trigger then skip this trigger + if (lastMatchedIndex == null && otherKeyIndex == otherTrigger.keys.lastIndex) { + continue@otherTriggerLoop } } } } + } - val parallelTriggersOverlappingParallelTriggers = - MutableList(triggers.size) { mutableSetOf() } - - for (triggerIndex in parallelTriggers) { - val trigger = triggers[triggerIndex] - - otherTriggerLoop@ for (otherTriggerIndex in parallelTriggers) { - val otherTrigger = triggers[otherTriggerIndex] + val parallelTriggersOverlappingParallelTriggers = + MutableList(triggers.size) { mutableSetOf() } - // Don't compare a trigger to itself - if (triggerIndex == otherTriggerIndex) { - continue@otherTriggerLoop - } - - // only check for overlapping if the other trigger has more keys - if (otherTrigger.keys.size <= trigger.keys.size) { - continue@otherTriggerLoop - } + for (triggerIndex in parallelTriggers) { + val trigger = triggers[triggerIndex] - for ((keyIndex, key) in trigger.keys.withIndex()) { - var lastMatchedIndex: Int? = null + otherTriggerLoop@ for (otherTriggerIndex in parallelTriggers) { + val otherTrigger = triggers[otherTriggerIndex] - for ((otherKeyIndex, otherKey) in otherTrigger.keys.withIndex()) { - if (otherKey.matchesWithOtherKey(key)) { + // Don't compare a trigger to itself + if (triggerIndex == otherTriggerIndex) { + continue@otherTriggerLoop + } - // make sure the overlap retains the order of the trigger - if (lastMatchedIndex != null && lastMatchedIndex != otherKeyIndex - 1) { - continue@otherTriggerLoop - } + // only check for overlapping if the other trigger has more keys + if (otherTrigger.keys.size <= trigger.keys.size) { + continue@otherTriggerLoop + } - if (keyIndex == trigger.keys.lastIndex) { - parallelTriggersOverlappingParallelTriggers[triggerIndex].add( - otherTriggerIndex, - ) - } + for ((keyIndex, key) in trigger.keys.withIndex()) { + var lastMatchedIndex: Int? = null - lastMatchedIndex = otherKeyIndex + for ((otherKeyIndex, otherKey) in otherTrigger.keys.withIndex()) { + if (otherKey.matchesWithOtherKey(key)) { + // make sure the overlap retains the order of the trigger + if (lastMatchedIndex != null && lastMatchedIndex != otherKeyIndex - 1) { + continue@otherTriggerLoop } - // if there were no matching keys in the other trigger then skip this trigger - if (lastMatchedIndex == null && otherKeyIndex == otherTrigger.keys.lastIndex) { - continue@otherTriggerLoop + if (keyIndex == trigger.keys.lastIndex) { + parallelTriggersOverlappingParallelTriggers[triggerIndex].add( + otherTriggerIndex, + ) } + + lastMatchedIndex = otherKeyIndex + } + + // if there were no matching keys in the other trigger then skip this trigger + if (lastMatchedIndex == null && otherKeyIndex == otherTrigger.keys.lastIndex) { + continue@otherTriggerLoop } } } } + } - for (triggerIndex in parallelTriggers) { - val trigger = triggers[triggerIndex] + for (triggerIndex in parallelTriggers) { + val trigger = triggers[triggerIndex] - trigger.keys.forEachIndexed { keyIndex, key -> - if (key is KeyCodeTriggerKey && isModifierKey(key.keyCode)) { - parallelTriggerModifierKeyIndices.add(triggerIndex to keyIndex) - } + trigger.keys.forEachIndexed { keyIndex, key -> + if (key is KeyCodeTriggerKey && isModifierKey(key.keyCode)) { + parallelTriggerModifierKeyIndices.add(triggerIndex to keyIndex) } } + } - reset() + reset() - this.triggers = triggers.toTypedArray() - this.triggerActions = triggerActions.toTypedArray() - this.triggerConstraints = triggerConstraints.toTypedArray() + this.triggers = triggers.toTypedArray() + this.triggerActions = triggerActions.toTypedArray() + this.triggerConstraints = triggerConstraints.toTypedArray() - this.sequenceTriggers = sequenceTriggers.toIntArray() - this.sequenceTriggersOverlappingSequenceTriggers = - sequenceTriggersOverlappingSequenceTriggers.map { it.toIntArray() } - .toTypedArray() + this.sequenceTriggers = sequenceTriggers.toIntArray() + this.sequenceTriggersOverlappingSequenceTriggers = + sequenceTriggersOverlappingSequenceTriggers.map { it.toIntArray() } + .toTypedArray() - this.sequenceTriggersOverlappingParallelTriggers = - sequenceTriggersOverlappingParallelTriggers.map { it.toIntArray() } - .toTypedArray() + this.sequenceTriggersOverlappingParallelTriggers = + sequenceTriggersOverlappingParallelTriggers.map { it.toIntArray() } + .toTypedArray() - this.parallelTriggers = parallelTriggers.toIntArray() - this.parallelTriggerModifierKeyIndices = - parallelTriggerModifierKeyIndices.toTypedArray() + this.parallelTriggers = parallelTriggers.toIntArray() + this.parallelTriggerModifierKeyIndices = + parallelTriggerModifierKeyIndices.toTypedArray() - this.parallelTriggersOverlappingParallelTriggers = - parallelTriggersOverlappingParallelTriggers - .map { it.toIntArray() } - .toTypedArray() + this.parallelTriggersOverlappingParallelTriggers = + parallelTriggersOverlappingParallelTriggers + .map { it.toIntArray() } + .toTypedArray() - parallelTriggersAwaitingReleaseAfterBeingTriggered = - BooleanArray(triggers.size) + parallelTriggersAwaitingReleaseAfterBeingTriggered = + BooleanArray(triggers.size) - detectSequenceLongPresses = longPressSequenceTriggerKeys.isNotEmpty() - this.longPressSequenceTriggerKeys = longPressSequenceTriggerKeys.toTypedArray() + detectSequenceLongPresses = longPressSequenceTriggerKeys.isNotEmpty() + this.longPressSequenceTriggerKeys = longPressSequenceTriggerKeys.toTypedArray() - detectSequenceDoublePresses = doublePressKeys.isNotEmpty() - this.doublePressTriggerKeys = doublePressKeys.toTypedArray() + detectSequenceDoublePresses = doublePressKeys.isNotEmpty() + this.doublePressTriggerKeys = doublePressKeys.toTypedArray() - this.parallelTriggerActionPerformers = parallelTriggerActionPerformers - this.sequenceTriggerActionPerformers = sequenceTriggerActionPerformers + this.parallelTriggerActionPerformers = parallelTriggerActionPerformers + this.sequenceTriggerActionPerformers = sequenceTriggerActionPerformers - this.triggerKeysThatSendRepeatedKeyEvents = triggerKeysThatSendRepeatedKeyEvents + this.triggerKeysThatSendRepeatedKeyEvents = triggerKeysThatSendRepeatedKeyEvents - reset() - } - - field = value + reset() } + } private var detectKeyMaps: Boolean = false private var detectInternalEvents: Boolean = false @@ -452,7 +445,7 @@ class KeyMapController( /** * An array of the constraints for every trigger */ - private var triggerConstraints: Array = arrayOf() + private var triggerConstraints: Array> = arrayOf() /** * The events to detect for each parallel trigger. @@ -580,7 +573,7 @@ class KeyMapController( coroutineScope.launch { useCase.allKeyMapList.collectLatest { keyMapList -> reset() - this@KeyMapController.keyMapList = keyMapList + loadKeyMaps(keyMapList) } } } @@ -709,9 +702,9 @@ class KeyMapController( val triggersSatisfiedByConstraints = mutableSetOf() for (triggerIndex in parallelTriggers.plus(sequenceTriggers)) { - val constraintState = triggerConstraints[triggerIndex] + val constraintStates = triggerConstraints[triggerIndex] - if (constraintSnapshot.isSatisfied(constraintState)) { + if (constraintSnapshot.isSatisfied(*constraintStates)) { triggersSatisfiedByConstraints.add(triggerIndex) } } @@ -1103,11 +1096,9 @@ class KeyMapController( triggers[eventLocation.triggerIndex].keys[eventLocation.keyIndex] val triggerIndex = eventLocation.triggerIndex - val constraintState = triggerConstraints[triggerIndex] + val constraintStates = triggerConstraints[triggerIndex] - if (constraintState.constraints.isNotEmpty()) { - if (!constraintSnapshot.isSatisfied(constraintState)) continue - } + if (!constraintSnapshot.isSatisfied(*constraintStates)) continue if (lastMatchedEventIndices[triggerIndex] != eventLocation.keyIndex - 1) continue @@ -1155,12 +1146,10 @@ class KeyMapController( triggerLoop@ for (triggerIndex in sequenceTriggers) { val trigger = triggers[triggerIndex] - val constraintState = triggerConstraints[triggerIndex] + val constraintStates = triggerConstraints[triggerIndex] val lastMatchedEventIndex = lastMatchedEventIndices[triggerIndex] - if (constraintState.constraints.isNotEmpty()) { - if (!constraintSnapshot.isSatisfied(constraintState)) continue - } + if (!constraintSnapshot.isSatisfied(*constraintStates)) continue // the index of the next event to match in the trigger val nextIndex = lastMatchedEventIndex + 1 diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt new file mode 100644 index 0000000000..3730a02096 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import androidx.compose.runtime.Composable + +@Composable +fun HandleAssistantTriggerSetupBottomSheet( + viewModel: ConfigTriggerViewModel, +) { +} diff --git a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt index e042689d53..3d2a18d9cf 100644 --- a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt +++ b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt @@ -4,7 +4,7 @@ import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.FingerprintGesturesSupportedUseCase -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCase import io.github.sds100.keymapper.system.devices.DevicesAdapter @@ -25,7 +25,7 @@ class AccessibilityServiceController( detectKeyMapsUseCase: DetectKeyMapsUseCase, fingerprintGesturesSupportedUseCase: FingerprintGesturesSupportedUseCase, rerouteKeyEventsUseCase: RerouteKeyEventsUseCase, - pauseMappingsUseCase: PauseMappingsUseCase, + pauseKeyMapsUseCase: PauseKeyMapsUseCase, devicesAdapter: DevicesAdapter, suAdapter: SuAdapter, inputMethodAdapter: InputMethodAdapter, @@ -40,7 +40,7 @@ class AccessibilityServiceController( detectKeyMapsUseCase, fingerprintGesturesSupportedUseCase, rerouteKeyEventsUseCase, - pauseMappingsUseCase, + pauseKeyMapsUseCase, devicesAdapter, suAdapter, inputMethodAdapter, diff --git a/app/src/main/assets/whats-new.txt b/app/src/main/assets/whats-new.txt index 0b7e9f81fc..35aa74b350 100644 --- a/app/src/main/assets/whats-new.txt +++ b/app/src/main/assets/whats-new.txt @@ -1,8 +1,11 @@ Key Mapper 3.0 is here! 🎉 🫧 This release introduces Floating Buttons: you can create custom on-screen buttons to trigger key maps. -❤️ There are also tonnes of improvements to make your key mapping experience more enjoyable. -👀 Grouping key maps into folders with shared constraints are in the pipeline. +🗂️ Grouping key maps into folders with shared constraints. + +🔦 You can now change the flashlight brightness. Tip: use the constraint for when the flashlight is showing to remap your volume buttons to change the brightness. + +❤️ There are also tonnes of improvements to make your key mapping experience more enjoyable. See all the changes at http://changelog.keymapper.club. \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index f5f1eefda7..92ddd731f7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -218,7 +218,7 @@ class KeyMapperApp : MultiDexApplication() { suAdapter, permissionAdapter, ), - UseCases.pauseMappings(this), + UseCases.pauseKeyMaps(this), UseCases.showImePicker(this), UseCases.controlAccessibilityService(this), UseCases.toggleCompatibleIme(this), @@ -231,7 +231,7 @@ class KeyMapperApp : MultiDexApplication() { appCoroutineScope, ServiceLocator.settingsRepository(this), ServiceLocator.inputMethodAdapter(this), - UseCases.pauseMappings(this), + UseCases.pauseKeyMaps(this), devicesAdapter, popupMessageAdapter, resourceProvider, diff --git a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt index b6400a027d..00f14520f2 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -10,9 +10,11 @@ import io.github.sds100.keymapper.backup.BackupManagerImpl import io.github.sds100.keymapper.data.db.AppDatabase import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository +import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.data.repositories.RoomFloatingButtonRepository import io.github.sds100.keymapper.data.repositories.RoomFloatingLayoutRepository +import io.github.sds100.keymapper.data.repositories.RoomGroupRepository import io.github.sds100.keymapper.data.repositories.RoomKeyMapRepository import io.github.sds100.keymapper.data.repositories.RoomLogRepository import io.github.sds100.keymapper.data.repositories.SettingsPreferenceRepository @@ -138,6 +140,20 @@ object ServiceLocator { } } + @Volatile + private var groupRepository: GroupRepository? = null + + fun groupRepository(context: Context): GroupRepository { + synchronized(this) { + return groupRepository ?: RoomGroupRepository( + database(context).groupDao(), + (context.applicationContext as KeyMapperApp).appCoroutineScope, + ).also { + this.groupRepository = it + } + } + } + @Volatile private var backupManager: BackupManager? = null @@ -156,6 +172,7 @@ object ServiceLocator { settingsRepository(context), floatingLayoutRepository(context), floatingButtonRepository(context), + groupRepository(context), soundsManager(context), ) @@ -281,6 +298,7 @@ object ServiceLocator { AppDatabase.RoomMigration11To12(context.applicationContext.legacyFingerprintMapDataStore), AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_13_14, + AppDatabase.MIGRATION_17_18, ).build() private val Context.legacyFingerprintMapDataStore by preferencesDataStore("fingerprint_gestures") diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index 21fb7e0c6b..22e395491a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -10,7 +10,7 @@ import io.github.sds100.keymapper.constraints.GetConstraintErrorUseCaseImpl import io.github.sds100.keymapper.floating.ListFloatingLayoutsUseCase import io.github.sds100.keymapper.floating.ListFloatingLayoutsUseCaseImpl import io.github.sds100.keymapper.mappings.FingerprintGesturesSupportedUseCaseImpl -import io.github.sds100.keymapper.mappings.PauseMappingsUseCaseImpl +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCaseImpl import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutUseCaseImpl import io.github.sds100.keymapper.mappings.keymaps.DisplayKeyMapUseCase @@ -97,7 +97,7 @@ object UseCases { fun fingerprintGesturesSupported(ctx: Context) = FingerprintGesturesSupportedUseCaseImpl(ServiceLocator.settingsRepository(ctx)) - fun pauseMappings(ctx: Context) = PauseMappingsUseCaseImpl( + fun pauseKeyMaps(ctx: Context) = PauseKeyMapsUseCaseImpl( ServiceLocator.settingsRepository(ctx), ServiceLocator.mediaAdapter(ctx), ) @@ -172,6 +172,7 @@ object UseCases { ) = DetectKeyMapsUseCaseImpl( ServiceLocator.roomKeyMapRepository(ctx), ServiceLocator.floatingButtonRepository(ctx), + ServiceLocator.groupRepository(ctx), ServiceLocator.settingsRepository(ctx), ServiceLocator.suAdapter(ctx), ServiceLocator.displayAdapter(ctx), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt b/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt index a28bfa8d13..df4eccb63b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt @@ -39,7 +39,7 @@ data class Action( } } -object KeymapActionEntityMapper { +object ActionEntityMapper { fun fromEntity(entity: ActionEntity): Action? { val data = ActionDataEntityMapper.fromEntity(entity) ?: return null diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt index 7ab1898c56..bf9a9d70d7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt @@ -7,7 +7,7 @@ import androidx.compose.material.icons.outlined.Android import io.github.sds100.keymapper.R import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.mappings.keymaps.KeyMap -import io.github.sds100.keymapper.system.camera.CameraLensUtils +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.devices.InputDeviceUtils import io.github.sds100.keymapper.system.display.OrientationUtils import io.github.sds100.keymapper.system.inputevents.InputEventUtils @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.system.volume.DndModeUtils import io.github.sds100.keymapper.system.volume.RingerModeUtils import io.github.sds100.keymapper.system.volume.VolumeStreamUtils import io.github.sds100.keymapper.util.handle +import io.github.sds100.keymapper.util.toPercentString import io.github.sds100.keymapper.util.ui.IconInfo import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.TintType @@ -241,53 +242,85 @@ class ActionUiHelper( ) is ActionData.Flashlight -> { - val lensString = getString(CameraLensUtils.getLabel(action.lens)) - when (action) { is ActionData.Flashlight.Toggle -> { if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - getString(R.string.action_toggle_flashlight_formatted, lensString) + if (action.lens == CameraLens.FRONT) { + getString(R.string.action_toggle_front_flashlight_formatted) + } else { + getString(R.string.action_toggle_flashlight_formatted) + } } else { - getString( - R.string.action_toggle_flashlight_with_strength, - arrayOf( - lensString, - (action.strengthPercent * 100).toInt(), - ), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_toggle_front_flashlight_with_strength, + action.strengthPercent.toPercentString(), + + ) + } else { + getString( + R.string.action_toggle_flashlight_with_strength, + action.strengthPercent.toPercentString(), + ) + } } } is ActionData.Flashlight.Enable -> { if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - getString(R.string.action_enable_flashlight_formatted, lensString) + if (action.lens == CameraLens.FRONT) { + getString(R.string.action_enable_front_flashlight_formatted) + } else { + getString(R.string.action_enable_flashlight_formatted) + } } else { - getString( - R.string.action_enable_flashlight_with_strength, - arrayOf( - lensString, - (action.strengthPercent * 100).toInt(), - ), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_enable_front_flashlight_with_strength, + action.strengthPercent.toPercentString(), + ) + } else { + getString( + R.string.action_enable_flashlight_with_strength, + action.strengthPercent.toPercentString(), + ) + } } } - is ActionData.Flashlight.Disable -> getString( - R.string.action_disable_flashlight_formatted, - lensString, - ) + is ActionData.Flashlight.Disable -> { + if (action.lens == CameraLens.FRONT) { + getString(R.string.action_disable_front_flashlight_formatted) + } else { + getString(R.string.action_disable_flashlight_formatted) + } + } is ActionData.Flashlight.ChangeStrength -> { if (action.percent > 0) { - getString( - R.string.action_flashlight_increase_strength_formatted, - arrayOf(lensString, (action.percent * 100).toInt()), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_front_flashlight_increase_strength_formatted, + action.percent.toPercentString(), + ) + } else { + getString( + R.string.action_flashlight_increase_strength_formatted, + action.percent.toPercentString(), + ) + } } else { - getString( - R.string.action_flashlight_decrease_strength_formatted, - arrayOf(lensString, (action.percent * 100).toInt()), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_front_flashlight_decrease_strength_formatted, + action.percent.toPercentString(), + ) + } else { + getString( + R.string.action_flashlight_decrease_strength_formatted, + action.percent.toPercentString(), + ) + } } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt index 9e617c4f98..ee4821ee43 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.FlashlightOn +import androidx.compose.material.icons.rounded.Pinch import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -148,7 +149,7 @@ private fun ActionsScreen( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = state.data.shortcuts, onClick = onClickShortcut, @@ -282,7 +283,9 @@ private fun ActionList( Spacer(Modifier.height(8.dp)) ShortcutRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), shortcuts = shortcuts, onClick = { onClickShortcut(it) }, ) @@ -308,6 +311,11 @@ private fun EmptyPreview() { strengthPercent = null, ), ), + ShortcutModel( + icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), + text = "Pinch in with 2 finger(s) on coordinates 5/4 with a pinch distance of 8px in 200ms", + data = ActionData.ConsumeKeyEvent, + ), ), ), ), @@ -350,6 +358,11 @@ private fun LoadedPreview() { strengthPercent = null, ), ), + ShortcutModel( + icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), + text = "Pinch in with 2 finger(s) on coordinates 5/4 with a pinch distance of 8px in 200ms", + data = ActionData.ConsumeKeyEvent, + ), ), isReorderingEnabled = true, ), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt index 0afd7eb02c..2d5ec2105a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ConfigActionsViewModel.kt @@ -1,7 +1,6 @@ package io.github.sds100.keymapper.actions import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.home.ChooseAppStoreModel import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.ShortcutModel @@ -14,6 +13,7 @@ import io.github.sds100.keymapper.util.getFullMessage import io.github.sds100.keymapper.util.isFixable import io.github.sds100.keymapper.util.mapData import io.github.sds100.keymapper.util.onFailure +import io.github.sds100.keymapper.util.ui.ChooseAppStoreModel import io.github.sds100.keymapper.util.ui.DialogResponse import io.github.sds100.keymapper.util.ui.LinkType import io.github.sds100.keymapper.util.ui.NavDestination diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt index df075c345e..1482dd5327 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt @@ -281,9 +281,7 @@ private fun ChangeFlashlightStrengthActionBottomSheet( onDismissRequest = onDismissRequest, title = stringResource(R.string.action_flashlight_change_strength), selectedLens = state.selectedLens, - availableLenses = state.lensData.entries - .filter { it.value.supportsVariableStrength } - .map { it.key }.toSet(), + availableLenses = state.lensData.entries.map { it.key }.toSet(), onSelectLens = onSelectLens, onDoneClick = onDoneClick, ) { @@ -371,28 +369,30 @@ private fun FlashlightActionBottomSheet( Spacer(modifier = Modifier.height(8.dp)) - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.CameraFront, - text = stringResource(R.string.action_config_flashlight_choose_side), - ) - - Row(modifier = Modifier.padding(horizontal = 8.dp)) { - RadioButtonText( - modifier = Modifier, - text = stringResource(R.string.lens_front), - isSelected = selectedLens == CameraLens.FRONT, - onSelected = { onSelectLens(CameraLens.FRONT) }, - isEnabled = availableLenses.contains(CameraLens.FRONT), + if (availableLenses.size > 1) { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.CameraFront, + text = stringResource(R.string.action_config_flashlight_choose_side), ) - RadioButtonText( - modifier = Modifier, - text = stringResource(R.string.lens_back), - isSelected = selectedLens == CameraLens.BACK, - onSelected = { onSelectLens(CameraLens.BACK) }, - isEnabled = availableLenses.contains(CameraLens.BACK), - ) + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_front), + isSelected = selectedLens == CameraLens.FRONT, + onSelected = { onSelectLens(CameraLens.FRONT) }, + isEnabled = availableLenses.contains(CameraLens.FRONT), + ) + + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_back), + isSelected = selectedLens == CameraLens.BACK, + onSelected = { onSelectLens(CameraLens.BACK) }, + isEnabled = availableLenses.contains(CameraLens.BACK), + ) + } } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt b/app/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt index c5418a5237..662687d589 100644 --- a/app/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt +++ b/app/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt @@ -16,7 +16,7 @@ class PauseMappingsBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context ?: return - val useCase = UseCases.pauseMappings(context) + val useCase = UseCases.pauseKeyMaps(context) when (intent?.action) { Api.ACTION_PAUSE_MAPPINGS -> useCase.pause() diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt index 2ee833671e..d9664aaba6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt +++ b/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt @@ -3,8 +3,10 @@ package io.github.sds100.keymapper.backup import com.google.gson.annotations.SerializedName import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity +import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity +// TODO back up groups that are referenced by key maps - back up all the children as well. If the parent is not included in the back up then set the parent uid to null data class BackupContent( @SerializedName(NAME_DB_VERSION) val dbVersion: Int, @@ -38,6 +40,9 @@ data class BackupContent( @SerializedName(NAME_FLOATING_BUTTONS) val floatingButtons: List? = null, + + @SerializedName(NAME_GROUPS) + val groups: List? = null, ) { companion object { const val NAME_DB_VERSION = "keymap_db_version" @@ -51,6 +56,7 @@ data class BackupContent( const val NAME_DEFAULT_SEQUENCE_TRIGGER_TIMEOUT = "default_sequence_trigger_timeout" const val NAME_FLOATING_LAYOUTS = "floating_layouts" const val NAME_FLOATING_BUTTONS = "floating_buttons" + const val NAME_GROUPS = "groups" @Deprecated("Device info used to be stored in a database table but they are now stored inside the triggers and actions.") const val NAME_DEVICE_INFO = "device_info" diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt index ce64fe44ca..4d0a584670 100644 --- a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt +++ b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.backup -import android.database.sqlite.SQLiteConstraintException import com.github.salomonbrys.kotson.byInt import com.github.salomonbrys.kotson.byNullableArray import com.github.salomonbrys.kotson.byNullableInt @@ -27,6 +26,8 @@ import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingButtonKeyEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity +import io.github.sds100.keymapper.data.entities.FloatingLayoutEntityWithButtons +import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity @@ -40,7 +41,9 @@ import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintMapM import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintToKeyMapMigration import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository +import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.data.repositories.RepositoryUtils import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.IFile @@ -57,13 +60,11 @@ import io.github.sds100.keymapper.util.then import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -82,6 +83,7 @@ class BackupManagerImpl( private val preferenceRepository: PreferenceRepository, private val floatingLayoutRepository: FloatingLayoutRepository, private val floatingButtonRepository: FloatingButtonRepository, + private val groupRepository: GroupRepository, private val soundsManager: SoundsManager, private val throwExceptions: Boolean = false, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), @@ -117,6 +119,7 @@ class BackupManagerImpl( .registerTypeAdapter(ConstraintEntity.DESERIALIZER) .registerTypeAdapter(FloatingLayoutEntity.DESERIALIZER) .registerTypeAdapter(FloatingButtonEntity.DESERIALIZER) + .registerTypeAdapter(GroupEntity.DESERIALIZER) .create() } @@ -124,39 +127,25 @@ class BackupManagerImpl( .get(Keys.automaticBackupLocation).map { it != null } init { - val doAutomaticBackup = MutableSharedFlow() - coroutineScope.launch { - doAutomaticBackup.collectLatest { backupData -> - if (!backupAutomatically.first()) return@collectLatest - - val backupLocation = preferenceRepository.get(Keys.automaticBackupLocation).first() - ?: return@collectLatest + combine( + backupAutomatically, + preferenceRepository.get(Keys.automaticBackupLocation), + keyMapRepository.keyMapList.filterIsInstance>>(), + groupRepository.getAllGroups(), + floatingLayoutRepository.layouts.filterIsInstance>>(), + ) { backupAutomatically, location, keyMaps, groups, floatingLayouts -> + if (!backupAutomatically) { + return@combine + } - val outputFile = fileAdapter.getFileFromUri(backupLocation) - val result = backupAsync(outputFile, backupData.keyMapList) + location ?: return@combine + val outputFile = fileAdapter.getFileFromUri(location) + val result = backupAsync(outputFile, keyMaps.data, groups, floatingLayouts.data) onAutomaticBackupResult.emit(result) - } - } - - coroutineScope.launch { - keyMapRepository.requestBackup.collectLatest { keyMapList -> - val backupData = AutomaticBackup(keyMapList = keyMapList) - - doAutomaticBackup.emit(backupData) - } + }.collect() } - - // automatically back up when the location changes - preferenceRepository.get(Keys.automaticBackupLocation).drop(1).onEach { - val keyMaps = - keyMapRepository.keyMapList.first { it is State.Data } as State.Data - - val data = AutomaticBackup(keyMapList = keyMaps.data) - - doAutomaticBackup.emit(data) - }.launchIn(coroutineScope) } override suspend fun backupKeyMaps(output: IFile, keyMapIds: List): Result { @@ -180,7 +169,13 @@ class BackupManagerImpl( .filterIsInstance>>() .first() - backupAsync(output, keyMaps.data) + val groups = groupRepository.getAllGroups().first() + + val layouts = floatingLayoutRepository.layouts + .filterIsInstance>>() + .first() + + backupAsync(output, keyMaps.data, groups, layouts.data) Success(Unit) } @@ -241,6 +236,18 @@ class BackupManagerImpl( // Do nothing because this just add the floating layouts table and indexes. JsonMigration(13, 14) { json -> json }, + + // Do nothing just added floating button entity columns + JsonMigration(14, 15) { json -> json }, + + // Do nothing just added nullable group uid column + JsonMigration(15, 16) { json -> json }, + + // Do nothing just added nullable column for when a group was last opened + JsonMigration(16, 17) { json -> json }, + + // Do nothing. It just removed the group name index. + JsonMigration(17, 18) { json -> json }, ) if (keyMapListJsonArray != null) { @@ -351,6 +358,10 @@ class BackupManagerImpl( val floatingButtons: List? = floatingButtonsJson?.map { json -> gson.fromJson(json) } + val groupsJson by rootElement.byNullableArray(BackupContent.NAME_GROUPS) + val groups: List = + groupsJson?.map { json -> gson.fromJson(json) } ?: emptyList() + val content = BackupContent( dbVersion = backupDbVersion, appVersion = backupAppVersion, @@ -363,6 +374,7 @@ class BackupManagerImpl( defaultSequenceTriggerTimeout = defaultSequenceTriggerTimeout, floatingLayouts = floatingLayouts, floatingButtons = floatingButtons, + groups = groups, ) return@withContext Success(content) @@ -435,12 +447,63 @@ class BackupManagerImpl( soundFiles: List, ): Result<*> { try { - when (restoreType) { - RestoreType.APPEND -> - appendKeyMapsInRepository(backupContent.keyMapList ?: emptyList()) + // MUST come before restoring key maps so it is possible to + // validate that each key map's group exists in the repository. + if (backupContent.groups != null) { + val groupUids = backupContent.groups.map { it.uid }.toMutableSet() + + val existingGroupUids = groupRepository.getAllGroups().first() + .map { it.uid } + .toSet() + .also { groupUids.addAll(it) } + + val currentTime = System.currentTimeMillis() + + for (group in backupContent.groups) { + // Set the last opened date to now so that the imported group + // shows as the most recent. + var modifiedGroup = group.copy(lastOpenedDate = currentTime) + + // If the group's parent wasn't backed up or doesn't exist + // then set it the parent to the root group + if (!groupUids.contains(group.parentUid)) { + modifiedGroup = modifiedGroup.copy(parentUid = null) + } + + val siblings = + groupRepository.getGroupsByParent(modifiedGroup.parentUid).first() + + modifiedGroup = RepositoryUtils.saveUniqueName( + modifiedGroup, + saveBlock = { renamedGroup -> + if (siblings.any { sibling -> sibling.name == renamedGroup.name }) { + throw IllegalStateException("Non unique group name") + } + }, + renameBlock = { entity, suffix -> + entity.copy(name = "${entity.name} $suffix") + }, + ) - RestoreType.REPLACE -> - replaceKeyMapsInRepository(backupContent.keyMapList ?: emptyList()) + if (existingGroupUids.contains(modifiedGroup.uid)) { + groupRepository.update(modifiedGroup) + } else { + groupRepository.insert(modifiedGroup) + } + } + } + + if (backupContent.keyMapList != null) { + val groups = groupRepository.getAllGroups().first() + val keyMapList = validateKeyMapGroups(backupContent.keyMapList, groups) + + when (restoreType) { + RestoreType.APPEND -> + appendKeyMapsInRepository(keyMapList) + + RestoreType.REPLACE -> + replaceKeyMapsInRepository(keyMapList) + } } if (backupContent.defaultLongPressDelay != null) { @@ -487,19 +550,13 @@ class BackupManagerImpl( if (backupContent.floatingLayouts != null) { for (layout in backupContent.floatingLayouts) { - var entity = layout - var subCount = 0 - - while (subCount < 1000) { - try { - floatingLayoutRepository.insert(entity) - break - } catch (_: SQLiteConstraintException) { - // If the name already exists try creating it with a new name. - entity = layout.copy(name = "${layout.name} (${subCount + 1})") - subCount++ - } - } + RepositoryUtils.saveUniqueName( + layout, + saveBlock = { floatingLayoutRepository.insert(it) }, + renameBlock = { entity, suffix -> + entity.copy(name = "${entity.name} $suffix") + }, + ) } } @@ -535,9 +592,30 @@ class BackupManagerImpl( keyMapRepository.insert(*keyMaps.toTypedArray()) } + /** + * Check whether the group each key map is assigned to actually exists. If it does not + * then move it to the root group by setting the group uid to null. + */ + private fun validateKeyMapGroups( + keyMaps: List, + groups: List, + ): List { + val groupMap = groups.associateBy { it.uid } + + return keyMaps.map { keyMap -> + if (keyMap.groupUid == null || groupMap.containsKey(keyMap.groupUid)) { + keyMap + } else { + keyMap.copy(groupUid = null) + } + } + } + private suspend fun backupAsync( output: IFile, - keyMapList: List? = null, + keyMapList: List = emptyList(), + extraGroups: List = emptyList(), + extraLayouts: List = emptyList(), ): Result { return withContext(dispatchers.io()) { val backupUid = uuidGenerator.random() @@ -549,64 +627,7 @@ class BackupManagerImpl( // delete the contents of the file output.clear() - val floatingLayouts: MutableList = mutableListOf() - val floatingButtons: MutableList = mutableListOf() - - if (keyMapList != null) { - val floatingButtonTriggerKeys = keyMapList - .flatMap { it.trigger.keys } - .filterIsInstance() - .map { it.buttonUid } - .distinct() - - for (buttonUid in floatingButtonTriggerKeys) { - val buttonWithLayout = floatingButtonRepository.get(buttonUid) ?: continue - - if (floatingLayouts.none { it.uid == buttonWithLayout.layout.uid }) { - floatingLayouts.add(buttonWithLayout.layout) - } - - floatingButtons.add(buttonWithLayout.button) - } - } - - val backupContent = BackupContent( - AppDatabase.DATABASE_VERSION, - Constants.VERSION_CODE, - keyMapList, - defaultLongPressDelay = - preferenceRepository - .get(Keys.defaultLongPressDelay) - .first() - .takeIf { it != PreferenceDefaults.LONG_PRESS_DELAY }, - defaultDoublePressDelay = - preferenceRepository - .get(Keys.defaultDoublePressDelay) - .first() - .takeIf { it != PreferenceDefaults.DOUBLE_PRESS_DELAY }, - defaultRepeatDelay = - preferenceRepository - .get(Keys.defaultRepeatDelay) - .first() - .takeIf { it != PreferenceDefaults.REPEAT_DELAY }, - defaultRepeatRate = - preferenceRepository - .get(Keys.defaultRepeatRate) - .first() - .takeIf { it != PreferenceDefaults.REPEAT_RATE }, - defaultSequenceTriggerTimeout = - preferenceRepository - .get(Keys.defaultSequenceTriggerTimeout) - .first() - .takeIf { it != PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT }, - defaultVibrationDuration = - preferenceRepository - .get(Keys.defaultVibrateDuration) - .first() - .takeIf { it != PreferenceDefaults.VIBRATION_DURATION }, - floatingLayouts = floatingLayouts.takeIf { it.isNotEmpty() }, - floatingButtons = floatingButtons.takeIf { it.isNotEmpty() }, - ) + val backupContent = createBackupContent(keyMapList, extraGroups, extraLayouts) val json = gson.toJson(backupContent) @@ -654,9 +675,100 @@ class BackupManagerImpl( } } - private data class AutomaticBackup( - val keyMapList: List?, - ) + suspend fun createBackupContent( + keyMapList: List, + extraGroups: List, + extraLayouts: List, + ): BackupContent { + val floatingLayoutsMap: MutableMap = mutableMapOf() + val floatingButtonsMap: MutableMap = mutableMapOf() + val groupMap: MutableMap = mutableMapOf() + + val floatingButtonTriggerKeys = keyMapList + .flatMap { it.trigger.keys } + .filterIsInstance() + .map { it.buttonUid } + .distinct() + + for (buttonUid in floatingButtonTriggerKeys) { + val buttonWithLayout = floatingButtonRepository.get(buttonUid) ?: continue + val layoutUid = buttonWithLayout.layout.uid + + if (!floatingLayoutsMap.containsKey(layoutUid)) { + floatingLayoutsMap[layoutUid] = buttonWithLayout.layout + } + + if (!floatingButtonsMap.containsKey(buttonUid)) { + floatingButtonsMap[buttonUid] = buttonWithLayout.button + } + } + + for (keyMap in keyMapList) { + val groupUid = keyMap.groupUid ?: continue + if (!groupMap.containsKey(groupUid)) { + val groupEntity = groupRepository.getGroup(groupUid) ?: continue + groupMap[groupUid] = groupEntity + } + } + + for (group in extraGroups) { + if (!groupMap.containsKey(group.uid)) { + groupMap[group.uid] = group + } + } + + for (layoutWithButtons in extraLayouts) { + if (!floatingLayoutsMap.containsKey(layoutWithButtons.layout.uid)) { + floatingLayoutsMap[layoutWithButtons.layout.uid] = layoutWithButtons.layout + } + + for (button in layoutWithButtons.buttons) { + if (!floatingButtonsMap.containsKey(button.uid)) { + floatingButtonsMap[button.uid] = button + } + } + } + + val backupContent = BackupContent( + AppDatabase.DATABASE_VERSION, + Constants.VERSION_CODE, + keyMapList, + defaultLongPressDelay = + preferenceRepository + .get(Keys.defaultLongPressDelay) + .first() + .takeIf { it != PreferenceDefaults.LONG_PRESS_DELAY }, + defaultDoublePressDelay = + preferenceRepository + .get(Keys.defaultDoublePressDelay) + .first() + .takeIf { it != PreferenceDefaults.DOUBLE_PRESS_DELAY }, + defaultRepeatDelay = + preferenceRepository + .get(Keys.defaultRepeatDelay) + .first() + .takeIf { it != PreferenceDefaults.REPEAT_DELAY }, + defaultRepeatRate = + preferenceRepository + .get(Keys.defaultRepeatRate) + .first() + .takeIf { it != PreferenceDefaults.REPEAT_RATE }, + defaultSequenceTriggerTimeout = + preferenceRepository + .get(Keys.defaultSequenceTriggerTimeout) + .first() + .takeIf { it != PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT }, + defaultVibrationDuration = + preferenceRepository + .get(Keys.defaultVibrateDuration) + .first() + .takeIf { it != PreferenceDefaults.VIBRATION_DURATION }, + floatingLayouts = floatingLayoutsMap.values.toList().takeIf { it.isNotEmpty() }, + floatingButtons = floatingButtonsMap.values.toList().takeIf { it.isNotEmpty() }, + groups = groupMap.values.toList().takeIf { it.isNotEmpty() }, + ) + return backupContent + } } interface BackupManager { diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt index 91e372af00..04a6070f6a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import io.github.sds100.keymapper.R import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.system.camera.CameraLensUtils import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.containsQuery @@ -112,8 +113,8 @@ class ChooseConstraintViewModel( ConstraintId.APP_NOT_PLAYING_MEDIA, -> onSelectAppConstraint(constraintType) - ConstraintId.MEDIA_PLAYING -> _returnResult.emit(Constraint.MediaPlaying) - ConstraintId.MEDIA_NOT_PLAYING -> _returnResult.emit(Constraint.NoMediaPlaying) + ConstraintId.MEDIA_PLAYING -> _returnResult.emit(Constraint.MediaPlaying()) + ConstraintId.MEDIA_NOT_PLAYING -> _returnResult.emit(Constraint.NoMediaPlaying()) ConstraintId.BT_DEVICE_CONNECTED, ConstraintId.BT_DEVICE_DISCONNECTED, @@ -125,35 +126,35 @@ class ChooseConstraintViewModel( ConstraintId.SCREEN_OFF -> onSelectScreenOffConstraint() ConstraintId.ORIENTATION_PORTRAIT -> - _returnResult.emit(Constraint.OrientationPortrait) + _returnResult.emit(Constraint.OrientationPortrait()) ConstraintId.ORIENTATION_LANDSCAPE -> - _returnResult.emit(Constraint.OrientationLandscape) + _returnResult.emit(Constraint.OrientationLandscape()) ConstraintId.ORIENTATION_0 -> - _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_0)) + _returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_0)) ConstraintId.ORIENTATION_90 -> - _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_90)) + _returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_90)) ConstraintId.ORIENTATION_180 -> - _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_180)) + _returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_180)) ConstraintId.ORIENTATION_270 -> - _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_270)) + _returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_270)) ConstraintId.FLASHLIGHT_ON -> { val lens = chooseFlashlightLens() ?: return@launch - _returnResult.emit(Constraint.FlashlightOn(lens)) + _returnResult.emit(Constraint.FlashlightOn(lens = lens)) } ConstraintId.FLASHLIGHT_OFF -> { val lens = chooseFlashlightLens() ?: return@launch - _returnResult.emit(Constraint.FlashlightOff(lens)) + _returnResult.emit(Constraint.FlashlightOff(lens = lens)) } - ConstraintId.WIFI_ON -> _returnResult.emit(Constraint.WifiOn) - ConstraintId.WIFI_OFF -> _returnResult.emit(Constraint.WifiOff) + ConstraintId.WIFI_ON -> _returnResult.emit(Constraint.WifiOn()) + ConstraintId.WIFI_OFF -> _returnResult.emit(Constraint.WifiOff()) ConstraintId.WIFI_CONNECTED, ConstraintId.WIFI_DISCONNECTED, @@ -166,40 +167,43 @@ class ChooseConstraintViewModel( -> onSelectImeChosenConstraint(constraintType) ConstraintId.DEVICE_IS_LOCKED -> - _returnResult.emit(Constraint.DeviceIsLocked) + _returnResult.emit(Constraint.DeviceIsLocked()) ConstraintId.DEVICE_IS_UNLOCKED -> - _returnResult.emit(Constraint.DeviceIsUnlocked) + _returnResult.emit(Constraint.DeviceIsUnlocked()) ConstraintId.IN_PHONE_CALL -> - _returnResult.emit(Constraint.InPhoneCall) + _returnResult.emit(Constraint.InPhoneCall()) ConstraintId.NOT_IN_PHONE_CALL -> - _returnResult.emit(Constraint.NotInPhoneCall) + _returnResult.emit(Constraint.NotInPhoneCall()) ConstraintId.PHONE_RINGING -> - _returnResult.emit(Constraint.PhoneRinging) + _returnResult.emit(Constraint.PhoneRinging()) ConstraintId.CHARGING -> - _returnResult.emit(Constraint.Charging) + _returnResult.emit(Constraint.Charging()) ConstraintId.DISCHARGING -> - _returnResult.emit(Constraint.Discharging) + _returnResult.emit(Constraint.Discharging()) ConstraintId.LOCK_SCREEN_SHOWING -> - _returnResult.emit(Constraint.LockScreenShowing) + _returnResult.emit(Constraint.LockScreenShowing()) ConstraintId.LOCK_SCREEN_NOT_SHOWING -> - _returnResult.emit(Constraint.LockScreenNotShowing) + _returnResult.emit(Constraint.LockScreenNotShowing()) } } } private suspend fun chooseFlashlightLens(): CameraLens? { - val items = listOf( - CameraLens.FRONT to getString(R.string.lens_front), - CameraLens.BACK to getString(R.string.lens_back), - ) + val items = useCase.getFlashlightLenses().map { + it to getString(CameraLensUtils.getLabel(it)) + } + + if (items.size == 1) { + return items.first().first + } val dialog = PopupUi.SingleChoice(items) @@ -271,10 +275,10 @@ class ChooseConstraintViewModel( when (type) { ConstraintId.WIFI_CONNECTED -> - _returnResult.emit(Constraint.WifiConnected(chosenSSID)) + _returnResult.emit(Constraint.WifiConnected(ssid = chosenSSID)) ConstraintId.WIFI_DISCONNECTED -> - _returnResult.emit(Constraint.WifiDisconnected(chosenSSID)) + _returnResult.emit(Constraint.WifiDisconnected(ssid = chosenSSID)) else -> Unit } @@ -291,10 +295,20 @@ class ChooseConstraintViewModel( when (type) { ConstraintId.IME_CHOSEN -> - _returnResult.emit(Constraint.ImeChosen(imeInfo.id, imeInfo.label)) + _returnResult.emit( + Constraint.ImeChosen( + imeId = imeInfo.id, + imeLabel = imeInfo.label, + ), + ) ConstraintId.IME_NOT_CHOSEN -> - _returnResult.emit(Constraint.ImeNotChosen(imeInfo.id, imeInfo.label)) + _returnResult.emit( + Constraint.ImeNotChosen( + imeId = imeInfo.id, + imeLabel = imeInfo.label, + ), + ) else -> Unit } @@ -308,7 +322,7 @@ class ChooseConstraintViewModel( response ?: return - _returnResult.emit(Constraint.ScreenOn) + _returnResult.emit(Constraint.ScreenOn()) } private suspend fun onSelectScreenOffConstraint() { @@ -319,7 +333,7 @@ class ChooseConstraintViewModel( response ?: return - _returnResult.emit(Constraint.ScreenOff) + _returnResult.emit(Constraint.ScreenOff()) } private suspend fun onSelectBluetoothConstraint(type: ConstraintId) { @@ -337,13 +351,13 @@ class ChooseConstraintViewModel( val constraint = when (type) { ConstraintId.BT_DEVICE_CONNECTED -> Constraint.BtDeviceConnected( - device.address, - device.name, + bluetoothAddress = device.address, + deviceName = device.name, ) ConstraintId.BT_DEVICE_DISCONNECTED -> Constraint.BtDeviceDisconnected( - device.address, - device.name, + bluetoothAddress = device.address, + deviceName = device.name, ) else -> throw IllegalArgumentException("Don't know how to create $type constraint after choosing app") @@ -362,19 +376,19 @@ class ChooseConstraintViewModel( val constraint = when (type) { ConstraintId.APP_IN_FOREGROUND -> Constraint.AppInForeground( - packageName, + packageName = packageName, ) ConstraintId.APP_NOT_IN_FOREGROUND -> Constraint.AppNotInForeground( - packageName, + packageName = packageName, ) ConstraintId.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia( - packageName, + packageName = packageName, ) ConstraintId.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia( - packageName, + packageName = packageName, ) else -> throw IllegalArgumentException("Don't know how to create $type constraint after choosing app") diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt index 624fd654dd..16f3726d85 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt @@ -16,41 +16,54 @@ import java.util.UUID @Serializable sealed class Constraint { - val uid: String = UUID.randomUUID().toString() + abstract val uid: String abstract val id: ConstraintId @Serializable - data class AppInForeground(val packageName: String) : Constraint() { + data class AppInForeground( + override val uid: String = UUID.randomUUID().toString(), + val packageName: String, + ) : Constraint() { override val id: ConstraintId = ConstraintId.APP_IN_FOREGROUND } @Serializable - data class AppNotInForeground(val packageName: String) : Constraint() { + data class AppNotInForeground( + override val uid: String = UUID.randomUUID().toString(), + val packageName: String, + ) : Constraint() { override val id: ConstraintId = ConstraintId.APP_NOT_IN_FOREGROUND } @Serializable - data class AppPlayingMedia(val packageName: String) : Constraint() { + data class AppPlayingMedia( + override val uid: String = UUID.randomUUID().toString(), + val packageName: String, + ) : Constraint() { override val id: ConstraintId = ConstraintId.APP_PLAYING_MEDIA } @Serializable - data class AppNotPlayingMedia(val packageName: String) : Constraint() { + data class AppNotPlayingMedia( + override val uid: String = UUID.randomUUID().toString(), + val packageName: String, + ) : Constraint() { override val id: ConstraintId = ConstraintId.APP_NOT_PLAYING_MEDIA } @Serializable - data object MediaPlaying : Constraint() { + data class MediaPlaying(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.MEDIA_PLAYING } @Serializable - data object NoMediaPlaying : Constraint() { + data class NoMediaPlaying(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.MEDIA_NOT_PLAYING } @Serializable data class BtDeviceConnected( + override val uid: String = UUID.randomUUID().toString(), val bluetoothAddress: String, val deviceName: String, ) : Constraint() { @@ -59,6 +72,7 @@ sealed class Constraint { @Serializable data class BtDeviceDisconnected( + override val uid: String = UUID.randomUUID().toString(), val bluetoothAddress: String, val deviceName: String, ) : Constraint() { @@ -66,27 +80,30 @@ sealed class Constraint { } @Serializable - data object ScreenOn : Constraint() { + data class ScreenOn(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.SCREEN_ON } @Serializable - data object ScreenOff : Constraint() { + data class ScreenOff(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.SCREEN_OFF } @Serializable - data object OrientationPortrait : Constraint() { + data class OrientationPortrait(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.ORIENTATION_PORTRAIT } @Serializable - data object OrientationLandscape : Constraint() { + data class OrientationLandscape(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.ORIENTATION_LANDSCAPE } @Serializable - data class OrientationCustom(val orientation: Orientation) : Constraint() { + data class OrientationCustom( + override val uid: String = UUID.randomUUID().toString(), + val orientation: Orientation, + ) : Constraint() { override val id: ConstraintId = when (orientation) { Orientation.ORIENTATION_0 -> ConstraintId.ORIENTATION_0 Orientation.ORIENTATION_90 -> ConstraintId.ORIENTATION_90 @@ -96,27 +113,34 @@ sealed class Constraint { } @Serializable - data class FlashlightOn(val lens: CameraLens) : Constraint() { + data class FlashlightOn( + override val uid: String = UUID.randomUUID().toString(), + val lens: CameraLens, + ) : Constraint() { override val id: ConstraintId = ConstraintId.FLASHLIGHT_ON } @Serializable - data class FlashlightOff(val lens: CameraLens) : Constraint() { + data class FlashlightOff( + override val uid: String = UUID.randomUUID().toString(), + val lens: CameraLens, + ) : Constraint() { override val id: ConstraintId = ConstraintId.FLASHLIGHT_OFF } @Serializable - data object WifiOn : Constraint() { + data class WifiOn(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.WIFI_ON } @Serializable - data object WifiOff : Constraint() { + data class WifiOff(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.WIFI_OFF } @Serializable data class WifiConnected( + override val uid: String = UUID.randomUUID().toString(), val ssid: String?, ) : Constraint() { override val id: ConstraintId = ConstraintId.WIFI_CONNECTED @@ -124,6 +148,7 @@ sealed class Constraint { @Serializable data class WifiDisconnected( + override val uid: String = UUID.randomUUID().toString(), val ssid: String?, ) : Constraint() { override val id: ConstraintId = ConstraintId.WIFI_DISCONNECTED @@ -131,6 +156,7 @@ sealed class Constraint { @Serializable data class ImeChosen( + override val uid: String = UUID.randomUUID().toString(), val imeId: String, val imeLabel: String, ) : Constraint() { @@ -139,6 +165,7 @@ sealed class Constraint { @Serializable data class ImeNotChosen( + override val uid: String = UUID.randomUUID().toString(), val imeId: String, val imeLabel: String, ) : Constraint() { @@ -146,47 +173,47 @@ sealed class Constraint { } @Serializable - data object DeviceIsLocked : Constraint() { + data class DeviceIsLocked(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.DEVICE_IS_LOCKED } @Serializable - data object DeviceIsUnlocked : Constraint() { + data class DeviceIsUnlocked(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.DEVICE_IS_UNLOCKED } @Serializable - data object LockScreenShowing : Constraint() { + data class LockScreenShowing(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.LOCK_SCREEN_SHOWING } @Serializable - data object LockScreenNotShowing : Constraint() { + data class LockScreenNotShowing(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.LOCK_SCREEN_NOT_SHOWING } @Serializable - data object InPhoneCall : Constraint() { + data class InPhoneCall(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.IN_PHONE_CALL } @Serializable - data object NotInPhoneCall : Constraint() { + data class NotInPhoneCall(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.NOT_IN_PHONE_CALL } @Serializable - data object PhoneRinging : Constraint() { + data class PhoneRinging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.PHONE_RINGING } @Serializable - data object Charging : Constraint() { + data class Charging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.CHARGING } @Serializable - data object Discharging : Constraint() { + data class Discharging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.DISCHARGING } } @@ -243,52 +270,110 @@ object ConstraintEntityMapper { } return when (entity.type) { - ConstraintEntity.APP_FOREGROUND -> Constraint.AppInForeground(getPackageName()) - ConstraintEntity.APP_NOT_FOREGROUND -> Constraint.AppNotInForeground(getPackageName()) - ConstraintEntity.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia(getPackageName()) - ConstraintEntity.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia(getPackageName()) - ConstraintEntity.MEDIA_PLAYING -> Constraint.MediaPlaying - ConstraintEntity.NO_MEDIA_PLAYING -> Constraint.NoMediaPlaying + ConstraintEntity.APP_FOREGROUND -> Constraint.AppInForeground( + uid = entity.uid, + getPackageName(), + ) + + ConstraintEntity.APP_NOT_FOREGROUND -> Constraint.AppNotInForeground( + uid = entity.uid, + getPackageName(), + ) + + ConstraintEntity.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia( + uid = entity.uid, + getPackageName(), + ) + + ConstraintEntity.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia( + uid = entity.uid, + getPackageName(), + ) + + ConstraintEntity.MEDIA_PLAYING -> Constraint.MediaPlaying(uid = entity.uid) + ConstraintEntity.NO_MEDIA_PLAYING -> Constraint.NoMediaPlaying(uid = entity.uid) ConstraintEntity.BT_DEVICE_CONNECTED -> - Constraint.BtDeviceConnected(getBluetoothAddress(), getBluetoothDeviceName()) + Constraint.BtDeviceConnected( + uid = entity.uid, + getBluetoothAddress(), + getBluetoothDeviceName(), + ) ConstraintEntity.BT_DEVICE_DISCONNECTED -> - Constraint.BtDeviceDisconnected(getBluetoothAddress(), getBluetoothDeviceName()) + Constraint.BtDeviceDisconnected( + uid = entity.uid, + getBluetoothAddress(), + getBluetoothDeviceName(), + ) + + ConstraintEntity.ORIENTATION_0 -> Constraint.OrientationCustom( + uid = entity.uid, + Orientation.ORIENTATION_0, + ) - ConstraintEntity.ORIENTATION_0 -> Constraint.OrientationCustom(Orientation.ORIENTATION_0) - ConstraintEntity.ORIENTATION_90 -> Constraint.OrientationCustom(Orientation.ORIENTATION_90) - ConstraintEntity.ORIENTATION_180 -> Constraint.OrientationCustom(Orientation.ORIENTATION_180) - ConstraintEntity.ORIENTATION_270 -> Constraint.OrientationCustom(Orientation.ORIENTATION_270) + ConstraintEntity.ORIENTATION_90 -> Constraint.OrientationCustom( + uid = entity.uid, + Orientation.ORIENTATION_90, + ) - ConstraintEntity.ORIENTATION_PORTRAIT -> Constraint.OrientationPortrait - ConstraintEntity.ORIENTATION_LANDSCAPE -> Constraint.OrientationLandscape + ConstraintEntity.ORIENTATION_180 -> Constraint.OrientationCustom( + uid = entity.uid, + Orientation.ORIENTATION_180, + ) - ConstraintEntity.SCREEN_OFF -> Constraint.ScreenOff - ConstraintEntity.SCREEN_ON -> Constraint.ScreenOn + ConstraintEntity.ORIENTATION_270 -> Constraint.OrientationCustom( + uid = entity.uid, + Orientation.ORIENTATION_270, + ) - ConstraintEntity.FLASHLIGHT_ON -> Constraint.FlashlightOn(getCameraLens()) - ConstraintEntity.FLASHLIGHT_OFF -> Constraint.FlashlightOff(getCameraLens()) + ConstraintEntity.ORIENTATION_PORTRAIT -> Constraint.OrientationPortrait(uid = entity.uid) + ConstraintEntity.ORIENTATION_LANDSCAPE -> Constraint.OrientationLandscape(uid = entity.uid) - ConstraintEntity.WIFI_ON -> Constraint.WifiOn - ConstraintEntity.WIFI_OFF -> Constraint.WifiOff - ConstraintEntity.WIFI_CONNECTED -> Constraint.WifiConnected(getSsid()) - ConstraintEntity.WIFI_DISCONNECTED -> Constraint.WifiDisconnected(getSsid()) + ConstraintEntity.SCREEN_OFF -> Constraint.ScreenOff(uid = entity.uid) + ConstraintEntity.SCREEN_ON -> Constraint.ScreenOn(uid = entity.uid) - ConstraintEntity.IME_CHOSEN -> Constraint.ImeChosen(getImeId(), getImeLabel()) - ConstraintEntity.IME_NOT_CHOSEN -> Constraint.ImeNotChosen(getImeId(), getImeLabel()) + ConstraintEntity.FLASHLIGHT_ON -> Constraint.FlashlightOn( + uid = entity.uid, + getCameraLens(), + ) - ConstraintEntity.DEVICE_IS_UNLOCKED -> Constraint.DeviceIsUnlocked - ConstraintEntity.DEVICE_IS_LOCKED -> Constraint.DeviceIsLocked - ConstraintEntity.LOCK_SCREEN_SHOWING -> Constraint.LockScreenShowing - ConstraintEntity.LOCK_SCREEN_NOT_SHOWING -> Constraint.LockScreenNotShowing + ConstraintEntity.FLASHLIGHT_OFF -> Constraint.FlashlightOff( + uid = entity.uid, + getCameraLens(), + ) + + ConstraintEntity.WIFI_ON -> Constraint.WifiOn(uid = entity.uid) + ConstraintEntity.WIFI_OFF -> Constraint.WifiOff(uid = entity.uid) + ConstraintEntity.WIFI_CONNECTED -> Constraint.WifiConnected(uid = entity.uid, getSsid()) + ConstraintEntity.WIFI_DISCONNECTED -> Constraint.WifiDisconnected( + uid = entity.uid, + getSsid(), + ) + + ConstraintEntity.IME_CHOSEN -> Constraint.ImeChosen( + uid = entity.uid, + getImeId(), + getImeLabel(), + ) + + ConstraintEntity.IME_NOT_CHOSEN -> Constraint.ImeNotChosen( + uid = entity.uid, + getImeId(), + getImeLabel(), + ) - ConstraintEntity.PHONE_RINGING -> Constraint.PhoneRinging - ConstraintEntity.IN_PHONE_CALL -> Constraint.InPhoneCall - ConstraintEntity.NOT_IN_PHONE_CALL -> Constraint.NotInPhoneCall + ConstraintEntity.DEVICE_IS_UNLOCKED -> Constraint.DeviceIsUnlocked(uid = entity.uid) + ConstraintEntity.DEVICE_IS_LOCKED -> Constraint.DeviceIsLocked(uid = entity.uid) + ConstraintEntity.LOCK_SCREEN_SHOWING -> Constraint.LockScreenShowing(uid = entity.uid) + ConstraintEntity.LOCK_SCREEN_NOT_SHOWING -> Constraint.LockScreenNotShowing(uid = entity.uid) - ConstraintEntity.CHARGING -> Constraint.Charging - ConstraintEntity.DISCHARGING -> Constraint.Discharging + ConstraintEntity.PHONE_RINGING -> Constraint.PhoneRinging(uid = entity.uid) + ConstraintEntity.IN_PHONE_CALL -> Constraint.InPhoneCall(uid = entity.uid) + ConstraintEntity.NOT_IN_PHONE_CALL -> Constraint.NotInPhoneCall(uid = entity.uid) + + ConstraintEntity.CHARGING -> Constraint.Charging(uid = entity.uid) + ConstraintEntity.DISCHARGING -> Constraint.Discharging(uid = entity.uid) else -> throw Exception("don't know how to convert constraint entity with type ${entity.type}") } @@ -296,6 +381,7 @@ object ConstraintEntityMapper { fun toEntity(constraint: Constraint): ConstraintEntity = when (constraint) { is Constraint.AppInForeground -> ConstraintEntity( + uid = constraint.uid, type = ConstraintEntity.APP_FOREGROUND, extras = listOf( EntityExtra( @@ -306,6 +392,7 @@ object ConstraintEntityMapper { ) is Constraint.AppNotInForeground -> ConstraintEntity( + uid = constraint.uid, type = ConstraintEntity.APP_NOT_FOREGROUND, extras = listOf( EntityExtra( @@ -316,6 +403,7 @@ object ConstraintEntityMapper { ) is Constraint.AppPlayingMedia -> ConstraintEntity( + uid = constraint.uid, type = ConstraintEntity.APP_PLAYING_MEDIA, extras = listOf( EntityExtra( @@ -326,6 +414,7 @@ object ConstraintEntityMapper { ) is Constraint.AppNotPlayingMedia -> ConstraintEntity( + uid = constraint.uid, type = ConstraintEntity.APP_NOT_PLAYING_MEDIA, extras = listOf( EntityExtra( @@ -335,10 +424,18 @@ object ConstraintEntityMapper { ), ) - Constraint.MediaPlaying -> ConstraintEntity(ConstraintEntity.MEDIA_PLAYING) - Constraint.NoMediaPlaying -> ConstraintEntity(ConstraintEntity.NO_MEDIA_PLAYING) + is Constraint.MediaPlaying -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.MEDIA_PLAYING, + ) + + is Constraint.NoMediaPlaying -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.NO_MEDIA_PLAYING, + ) is Constraint.BtDeviceConnected -> ConstraintEntity( + uid = constraint.uid, type = ConstraintEntity.BT_DEVICE_CONNECTED, extras = listOf( EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.bluetoothAddress), @@ -347,6 +444,7 @@ object ConstraintEntityMapper { ) is Constraint.BtDeviceDisconnected -> ConstraintEntity( + uid = constraint.uid, type = ConstraintEntity.BT_DEVICE_DISCONNECTED, extras = listOf( EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.bluetoothAddress), @@ -355,23 +453,55 @@ object ConstraintEntityMapper { ) is Constraint.OrientationCustom -> when (constraint.orientation) { - Orientation.ORIENTATION_0 -> ConstraintEntity(ConstraintEntity.ORIENTATION_0) - Orientation.ORIENTATION_90 -> ConstraintEntity(ConstraintEntity.ORIENTATION_90) - Orientation.ORIENTATION_180 -> ConstraintEntity(ConstraintEntity.ORIENTATION_180) - Orientation.ORIENTATION_270 -> ConstraintEntity(ConstraintEntity.ORIENTATION_270) + Orientation.ORIENTATION_0 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_0, + ) + + Orientation.ORIENTATION_90 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_90, + ) + + Orientation.ORIENTATION_180 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_180, + ) + + Orientation.ORIENTATION_270 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_270, + ) } - Constraint.OrientationLandscape -> ConstraintEntity(ConstraintEntity.ORIENTATION_LANDSCAPE) - Constraint.OrientationPortrait -> ConstraintEntity(ConstraintEntity.ORIENTATION_PORTRAIT) - Constraint.ScreenOff -> ConstraintEntity(ConstraintEntity.SCREEN_OFF) - Constraint.ScreenOn -> ConstraintEntity(ConstraintEntity.SCREEN_ON) + is Constraint.OrientationLandscape -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_LANDSCAPE, + ) + + is Constraint.OrientationPortrait -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_PORTRAIT, + ) + + is Constraint.ScreenOff -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.SCREEN_OFF, + ) + + is Constraint.ScreenOn -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.SCREEN_ON, + ) is Constraint.FlashlightOff -> ConstraintEntity( + uid = constraint.uid, ConstraintEntity.FLASHLIGHT_OFF, EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, LENS_MAP[constraint.lens]!!), ) is Constraint.FlashlightOn -> ConstraintEntity( + uid = constraint.uid, ConstraintEntity.FLASHLIGHT_ON, EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, LENS_MAP[constraint.lens]!!), ) @@ -383,7 +513,11 @@ object ConstraintEntityMapper { extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.ssid)) } - ConstraintEntity(ConstraintEntity.WIFI_CONNECTED, extras) + ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.WIFI_CONNECTED, + extras = extras, + ) } is Constraint.WifiDisconnected -> { @@ -393,14 +527,26 @@ object ConstraintEntityMapper { extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.ssid)) } - ConstraintEntity(ConstraintEntity.WIFI_DISCONNECTED, extras) + ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.WIFI_DISCONNECTED, + extras = extras, + ) } - Constraint.WifiOff -> ConstraintEntity(ConstraintEntity.WIFI_OFF) - Constraint.WifiOn -> ConstraintEntity(ConstraintEntity.WIFI_ON) + is Constraint.WifiOff -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.WIFI_OFF, + ) + + is Constraint.WifiOn -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.WIFI_ON, + ) is Constraint.ImeChosen -> { ConstraintEntity( + uid = constraint.uid, ConstraintEntity.IME_CHOSEN, EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.imeId), EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.imeLabel), @@ -409,20 +555,56 @@ object ConstraintEntityMapper { is Constraint.ImeNotChosen -> { ConstraintEntity( + uid = constraint.uid, ConstraintEntity.IME_NOT_CHOSEN, EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.imeId), EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.imeLabel), ) } - Constraint.DeviceIsLocked -> ConstraintEntity(ConstraintEntity.DEVICE_IS_LOCKED) - Constraint.DeviceIsUnlocked -> ConstraintEntity(ConstraintEntity.DEVICE_IS_UNLOCKED) - Constraint.LockScreenShowing -> ConstraintEntity(ConstraintEntity.LOCK_SCREEN_SHOWING) - Constraint.LockScreenNotShowing -> ConstraintEntity(ConstraintEntity.LOCK_SCREEN_NOT_SHOWING) - Constraint.InPhoneCall -> ConstraintEntity(ConstraintEntity.IN_PHONE_CALL) - Constraint.NotInPhoneCall -> ConstraintEntity(ConstraintEntity.NOT_IN_PHONE_CALL) - Constraint.PhoneRinging -> ConstraintEntity(ConstraintEntity.PHONE_RINGING) - Constraint.Charging -> ConstraintEntity(ConstraintEntity.CHARGING) - Constraint.Discharging -> ConstraintEntity(ConstraintEntity.DISCHARGING) + is Constraint.DeviceIsLocked -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.DEVICE_IS_LOCKED, + ) + + is Constraint.DeviceIsUnlocked -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.DEVICE_IS_UNLOCKED, + ) + + is Constraint.LockScreenShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.LOCK_SCREEN_SHOWING, + ) + + is Constraint.LockScreenNotShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.LOCK_SCREEN_NOT_SHOWING, + ) + + is Constraint.InPhoneCall -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.IN_PHONE_CALL, + ) + + is Constraint.NotInPhoneCall -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.NOT_IN_PHONE_CALL, + ) + + is Constraint.PhoneRinging -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.PHONE_RINGING, + ) + + is Constraint.Charging -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.CHARGING, + ) + + is Constraint.Discharging -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.DISCHARGING, + ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt index 8c84892d97..4ef6ceb373 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt @@ -91,9 +91,9 @@ class LazyConstraintSnapshot( appsPlayingMedia.none { it == constraint.packageName } && !(appInForeground == constraint.packageName && isMediaPlaying()) - Constraint.MediaPlaying -> isMediaPlaying() + is Constraint.MediaPlaying -> isMediaPlaying() - Constraint.NoMediaPlaying -> !isMediaPlaying() + is Constraint.NoMediaPlaying -> !isMediaPlaying() is Constraint.BtDeviceConnected -> { connectedBluetoothDevices.any { it.address == constraint.bluetoothAddress } @@ -104,14 +104,14 @@ class LazyConstraintSnapshot( } is Constraint.OrientationCustom -> orientation == constraint.orientation - Constraint.OrientationLandscape -> + is Constraint.OrientationLandscape -> orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 - Constraint.OrientationPortrait -> + is Constraint.OrientationPortrait -> orientation == Orientation.ORIENTATION_0 || orientation == Orientation.ORIENTATION_180 - Constraint.ScreenOff -> !isScreenOn - Constraint.ScreenOn -> isScreenOn + is Constraint.ScreenOff -> !isScreenOn + is Constraint.ScreenOn -> isScreenOn is Constraint.FlashlightOff -> !cameraAdapter.isFlashlightOn(constraint.lens) is Constraint.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.lens) is Constraint.WifiConnected -> { @@ -131,31 +131,31 @@ class LazyConstraintSnapshot( connectedWifiSSID != constraint.ssid } - Constraint.WifiOff -> !isWifiEnabled - Constraint.WifiOn -> isWifiEnabled + is Constraint.WifiOff -> !isWifiEnabled + is Constraint.WifiOn -> isWifiEnabled is Constraint.ImeChosen -> chosenImeId == constraint.imeId is Constraint.ImeNotChosen -> chosenImeId != constraint.imeId - Constraint.DeviceIsLocked -> isLocked - Constraint.DeviceIsUnlocked -> !isLocked - Constraint.InPhoneCall -> + is Constraint.DeviceIsLocked -> isLocked + is Constraint.DeviceIsUnlocked -> !isLocked + is Constraint.InPhoneCall -> callState == CallState.IN_PHONE_CALL || audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL) - Constraint.NotInPhoneCall -> + is Constraint.NotInPhoneCall -> callState == CallState.NONE && !audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL) - Constraint.PhoneRinging -> + is Constraint.PhoneRinging -> callState == CallState.RINGING || audioVolumeStreams.contains(AudioManager.STREAM_RING) - Constraint.Charging -> isCharging - Constraint.Discharging -> !isCharging + is Constraint.Charging -> isCharging + is Constraint.Discharging -> !isCharging // The keyguard manager still reports the lock screen as showing if you are in // an another activity like the camera app while the phone is locked. - Constraint.LockScreenShowing -> isLockscreenShowing && appInForeground == "com.android.systemui" - Constraint.LockScreenNotShowing -> !isLockscreenShowing || appInForeground != "com.android.systemui" + is Constraint.LockScreenShowing -> isLockscreenShowing && appInForeground == "com.android.systemui" + is Constraint.LockScreenNotShowing -> !isLockscreenShowing || appInForeground != "com.android.systemui" } if (isSatisfied) { @@ -172,19 +172,42 @@ interface ConstraintSnapshot { fun isSatisfied(constraint: Constraint): Boolean } -fun ConstraintSnapshot.isSatisfied(constraintState: ConstraintState): Boolean { - // Required in case OR is used with empty list of constraints. - if (constraintState.constraints.isEmpty()) { - return true - } +/** + * Whether multiple constraint states are satisfied. This does an AND on the + * constraint states. + */ +fun ConstraintSnapshot.isSatisfied(vararg constraintState: ConstraintState): Boolean { + for (state in constraintState) { + when (state.mode) { + ConstraintMode.AND -> { + for (constraint in state.constraints) { + if (!isSatisfied(constraint)) { + return false + } + } + } - return when (constraintState.mode) { - ConstraintMode.AND -> { - constraintState.constraints.all { isSatisfied(it) } - } + ConstraintMode.OR -> { + // If no constraints then still satisfied + if (state.constraints.isEmpty()) { + continue + } - ConstraintMode.OR -> { - constraintState.constraints.any { isSatisfied(it) } + var anySatisfied = false + + for (constraint in state.constraints) { + if (isSatisfied(constraint)) { + anySatisfied = true + break + } + } + + if (!anySatisfied) { + return false + } + } } } + + return true } diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt index 6c31092e83..7e2cc119d2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.constraints import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Android import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.system.camera.CameraLensUtils +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.util.handle import io.github.sds100.keymapper.util.ui.ResourceProvider @@ -50,8 +50,8 @@ class ConstraintUiHelper( onError = { getString(R.string.constraint_choose_app_playing_media) }, ) - Constraint.MediaPlaying -> getString(R.string.constraint_choose_media_playing) - Constraint.NoMediaPlaying -> getString(R.string.constraint_choose_media_not_playing) + is Constraint.MediaPlaying -> getString(R.string.constraint_choose_media_playing) + is Constraint.NoMediaPlaying -> getString(R.string.constraint_choose_media_not_playing) is Constraint.BtDeviceConnected -> getString( @@ -76,27 +76,29 @@ class ConstraintUiHelper( getString(resId) } - Constraint.OrientationLandscape -> + is Constraint.OrientationLandscape -> getString(R.string.constraint_choose_orientation_landscape) - Constraint.OrientationPortrait -> + is Constraint.OrientationPortrait -> getString(R.string.constraint_choose_orientation_portrait) - Constraint.ScreenOff -> + is Constraint.ScreenOff -> getString(R.string.constraint_screen_off_description) - Constraint.ScreenOn -> + is Constraint.ScreenOn -> getString(R.string.constraint_screen_on_description) - is Constraint.FlashlightOff -> getString( - R.string.constraint_flashlight_off_description, - getString(CameraLensUtils.getLabel(constraint.lens)), - ) + is Constraint.FlashlightOff -> if (constraint.lens == CameraLens.FRONT) { + getString(R.string.constraint_front_flashlight_off_description) + } else { + getString(R.string.constraint_flashlight_off_description) + } - is Constraint.FlashlightOn -> getString( - R.string.constraint_flashlight_on_description, - getString(CameraLensUtils.getLabel(constraint.lens)), - ) + is Constraint.FlashlightOn -> if (constraint.lens == CameraLens.FRONT) { + getString(R.string.constraint_front_flashlight_on_description) + } else { + getString(R.string.constraint_flashlight_on_description) + } is Constraint.WifiConnected -> { if (constraint.ssid == null) { @@ -114,8 +116,8 @@ class ConstraintUiHelper( } } - Constraint.WifiOff -> getString(R.string.constraint_wifi_off) - Constraint.WifiOn -> getString(R.string.constraint_wifi_on) + is Constraint.WifiOff -> getString(R.string.constraint_wifi_off) + is Constraint.WifiOn -> getString(R.string.constraint_wifi_on) is Constraint.ImeChosen -> { val label = getInputMethodLabel(constraint.imeId).valueIfFailure { @@ -133,15 +135,15 @@ class ConstraintUiHelper( getString(R.string.constraint_ime_not_chosen_description, label) } - Constraint.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) - Constraint.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) - Constraint.InPhoneCall -> getString(R.string.constraint_in_phone_call) - Constraint.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call) - Constraint.PhoneRinging -> getString(R.string.constraint_phone_ringing) - Constraint.Charging -> getString(R.string.constraint_charging) - Constraint.Discharging -> getString(R.string.constraint_discharging) - Constraint.LockScreenShowing -> getString(R.string.constraint_lock_screen_showing) - Constraint.LockScreenNotShowing -> getString(R.string.constraint_lock_screen_not_showing) + is Constraint.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) + is Constraint.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) + is Constraint.InPhoneCall -> getString(R.string.constraint_in_phone_call) + is Constraint.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call) + is Constraint.PhoneRinging -> getString(R.string.constraint_phone_ringing) + is Constraint.Charging -> getString(R.string.constraint_charging) + is Constraint.Discharging -> getString(R.string.constraint_discharging) + is Constraint.LockScreenShowing -> getString(R.string.constraint_lock_screen_showing) + is Constraint.LockScreenNotShowing -> getString(R.string.constraint_lock_screen_not_showing) } fun getIcon(constraint: Constraint): ComposeIconInfo = when (constraint) { diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt index d1c56cd317..bb2b78a354 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt @@ -140,7 +140,7 @@ private fun ConstraintsScreen( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = state.data.shortcuts, onClick = onClickShortcut, @@ -284,7 +284,9 @@ private fun ConstraintList( Spacer(Modifier.height(8.dp)) ShortcutRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), shortcuts = shortcuts, onClick = { onClickShortcut(it) }, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt index 144847c02f..761af23fc4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt @@ -1,8 +1,11 @@ package io.github.sds100.keymapper.constraints +import android.content.pm.PackageManager import android.os.Build import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.camera.CameraAdapter +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter @@ -19,15 +22,23 @@ class CreateConstraintUseCaseImpl( private val networkAdapter: NetworkAdapter, private val inputMethodAdapter: InputMethodAdapter, private val preferenceRepository: PreferenceRepository, + private val cameraAdapter: CameraAdapter, ) : CreateConstraintUseCase { override fun isSupported(constraint: ConstraintId): Error? { when (constraint) { - ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> + ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) } + if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && + cameraAdapter.getFlashInfo(CameraLens.FRONT) == null + ) { + return Error.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH) + } + } + ConstraintId.DEVICE_IS_LOCKED, ConstraintId.DEVICE_IS_UNLOCKED -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.LOLLIPOP_MR1) @@ -66,6 +77,10 @@ class CreateConstraintUseCaseImpl( override fun getSavedWifiSSIDs(): Flow> = preferenceRepository.get(Keys.savedWifiSSIDs) .map { it?.toList() ?: emptyList() } + + override fun getFlashlightLenses(): Set { + return CameraLens.entries.filter { cameraAdapter.getFlashInfo(it) != null }.toSet() + } } interface CreateConstraintUseCase { @@ -75,4 +90,6 @@ interface CreateConstraintUseCase { suspend fun saveWifiSSID(ssid: String) fun getSavedWifiSSIDs(): Flow> + + fun getFlashlightLenses(): Set } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt index ae579aede1..06ca3f1fec 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -52,6 +52,8 @@ object Keys { booleanPreferencesKey("key_shown_parallel_trigger_order_warning") val shownSequenceTriggerExplanation = booleanPreferencesKey("key_shown_sequence_trigger_explanation_dialog") + val shownKeyCodeToScanCodeTriggerExplanation = + booleanPreferencesKey("key_shown_keycode_to_scancode_trigger_explanation_dialog") val lastInstalledVersionCodeHomeScreen = intPreferencesKey("last_installed_version_home_screen") val lastInstalledVersionCodeBackground = diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 5260652290..e3a933e747 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.data.db import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @@ -11,6 +12,7 @@ import io.github.sds100.keymapper.data.db.AppDatabase.Companion.DATABASE_VERSION import io.github.sds100.keymapper.data.db.dao.FingerprintMapDao import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao import io.github.sds100.keymapper.data.db.dao.FloatingLayoutDao +import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.db.dao.LogEntryDao import io.github.sds100.keymapper.data.db.typeconverter.ActionListTypeConverter @@ -20,8 +22,12 @@ import io.github.sds100.keymapper.data.db.typeconverter.TriggerTypeConverter import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity +import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.LogEntryEntity +import io.github.sds100.keymapper.data.migration.AutoMigration14To15 +import io.github.sds100.keymapper.data.migration.AutoMigration15To16 +import io.github.sds100.keymapper.data.migration.AutoMigration16To17 import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -38,9 +44,17 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 * Created by sds100 on 24/01/2020. */ @Database( - entities = [KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::class], + entities = [KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::class, GroupEntity::class], version = DATABASE_VERSION, exportSchema = true, + autoMigrations = [ + // This adds the button and background opacity columns to the floating button entity + AutoMigration(from = 14, to = 15, spec = AutoMigration14To15::class), + // This deletes the folder name column from key maps + AutoMigration(from = 15, to = 16, spec = AutoMigration15To16::class), + // This adds last opened timestamp to groups + AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), + ], ) @TypeConverters( ActionListTypeConverter::class, @@ -51,7 +65,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 14 + const val DATABASE_VERSION = 18 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -126,6 +140,12 @@ abstract class AppDatabase : RoomDatabase() { Migration13To14.migrateDatabase(database) } } + + val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_groups_name`") + } + } } class RoomMigration11To12( @@ -141,4 +161,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun logEntryDao(): LogEntryDao abstract fun floatingLayoutDao(): FloatingLayoutDao abstract fun floatingButtonDao(): FloatingButtonDao + abstract fun groupDao(): GroupDao } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt index ea402266f6..a1449f92ba 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt @@ -23,6 +23,8 @@ interface FloatingButtonDao { const val KEY_ORIENTATION = "orientation" const val KEY_DISPLAY_WIDTH = "display_width" const val KEY_DISPLAY_HEIGHT = "display_height" + const val KEY_BORDER_OPACITY = "border_opacity" + const val KEY_BACKGROUND_OPACITY = "background_opacity" } @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt new file mode 100644 index 0000000000..b463a63f37 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt @@ -0,0 +1,61 @@ +package io.github.sds100.keymapper.data.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import io.github.sds100.keymapper.data.entities.GroupEntity +import io.github.sds100.keymapper.data.entities.GroupEntityWithChildren +import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup +import kotlinx.coroutines.flow.Flow + +@Dao +interface GroupDao { + companion object { + const val TABLE_NAME = "groups" + const val KEY_UID = "uid" + const val KEY_NAME = "name" + const val KEY_CONSTRAINTS = "constraints" + const val KEY_CONSTRAINT_MODE = "constraint_mode" + const val KEY_PARENT_UID = "parent_uid" + const val KEY_LAST_OPENED_DATE = "last_opened_date" + } + + @Query("SELECT * FROM $TABLE_NAME") + fun getAll(): Flow> + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:groupUid)") + fun getKeyMapsByGroup(groupUid: String): Flow + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") + fun getById(uid: String): GroupEntity? + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID IN (:uid)") + fun getManyByIdFlow(vararg uid: String): Flow> + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") + fun getByIdFlow(uid: String): Flow + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") + fun getGroupWithSubGroups(uid: String): Flow + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_PARENT_UID IS (:uid)") + fun getGroupsByParent(uid: String?): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(vararg group: GroupEntity) + + @Update(onConflict = OnConflictStrategy.ABORT) + suspend fun update(vararg group: GroupEntity) + + @Delete + suspend fun delete(vararg group: GroupEntity) + + @Query("DELETE FROM $TABLE_NAME WHERE $KEY_UID IN (:uid)") + suspend fun deleteByUid(vararg uid: String) + + @Query("UPDATE $TABLE_NAME SET $KEY_LAST_OPENED_DATE = (:timestamp) WHERE $KEY_UID IS (:groupUid)") + suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt index 077902627a..b5f7481efc 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt @@ -24,8 +24,8 @@ interface KeyMapDao { const val KEY_ACTION_LIST = "action_list" const val KEY_CONSTRAINT_LIST = "constraint_list" const val KEY_CONSTRAINT_MODE = "constraint_mode" - const val KEY_FOLDER_NAME = "folder_name" const val KEY_UID = "uid" + const val KEY_GROUP_UID = "group_uid" } @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_ID = (:id)") @@ -37,6 +37,10 @@ interface KeyMapDao { @Query("SELECT * FROM $TABLE_NAME") fun getAll(): Flow> + // Must use IS to check if it is null. + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_GROUP_UID IS (:groupUid)") + fun getByGroup(groupUid: String?): Flow> + @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=0") suspend fun disableAll() @@ -49,6 +53,9 @@ interface KeyMapDao { @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=0 WHERE $KEY_UID in (:uid)") suspend fun disableKeyMapByUid(vararg uid: String) + @Query("UPDATE $TABLE_NAME SET $KEY_GROUP_UID=(:groupUid) WHERE $KEY_UID in (:uid)") + suspend fun setKeyMapGroup(groupUid: String?, vararg uid: String) + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insert(vararg keyMap: KeyMapEntity) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt index 1c3f9d8f67..ccce6fc97c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ActionListTypeConverter.kt @@ -3,21 +3,22 @@ package io.github.sds100.keymapper.data.db.typeconverter import androidx.room.TypeConverter import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.registerTypeAdapter -import com.google.gson.Gson import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.ActionEntity +import io.github.sds100.keymapper.data.entities.ConstraintEntity /** * Created by sds100 on 05/09/2018. */ class ActionListTypeConverter { + private val gson = GsonBuilder().registerTypeAdapter(ConstraintEntity.DESERIALIZER).create() + @TypeConverter fun toActionList(json: String): List { - val gson = GsonBuilder().registerTypeAdapter(ActionEntity.DESERIALIZER).create() return gson.fromJson>(json) } @TypeConverter - fun toJsonString(actionList: List): String = Gson().toJson(actionList)!! + fun toJsonString(actionList: List): String = gson.toJson(actionList)!! } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt index 57a24cc4dc..7e7b43c0d1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt @@ -2,7 +2,8 @@ package io.github.sds100.keymapper.data.db.typeconverter import androidx.room.TypeConverter import com.github.salomonbrys.kotson.fromJson -import com.google.gson.Gson +import com.github.salomonbrys.kotson.registerTypeAdapter +import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.ConstraintEntity /** @@ -10,10 +11,11 @@ import io.github.sds100.keymapper.data.entities.ConstraintEntity */ class ConstraintListTypeConverter { + private val gson = GsonBuilder().registerTypeAdapter(ConstraintEntity.DESERIALIZER).create() + @TypeConverter - fun toConstraintList(json: String) = Gson().fromJson>(json) + fun toConstraintList(json: String) = gson.fromJson>(json) @TypeConverter - fun toJsonString(constraintList: List) = - Gson().toJson(constraintList)!! + fun toJsonString(constraintList: List) = gson.toJson(constraintList)!! } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ExtraListTypeConverter.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ExtraListTypeConverter.kt index d0e4750ecb..c7e5c038dc 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ExtraListTypeConverter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ExtraListTypeConverter.kt @@ -2,7 +2,7 @@ package io.github.sds100.keymapper.data.db.typeconverter import androidx.room.TypeConverter import com.github.salomonbrys.kotson.fromJson -import com.google.gson.Gson +import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.EntityExtra /** @@ -10,9 +10,11 @@ import io.github.sds100.keymapper.data.entities.EntityExtra */ class ExtraListTypeConverter { + private val gson = GsonBuilder().create() + @TypeConverter - fun toExtraObject(string: String) = Gson().fromJson>(string) + fun toExtraObject(string: String) = gson.fromJson>(string) @TypeConverter - fun toString(extras: List) = Gson().toJson(extras)!! + fun toString(extras: List) = gson.toJson(extras)!! } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt index bdbacaf8b1..58d967d6a6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt @@ -3,7 +3,6 @@ package io.github.sds100.keymapper.data.db.typeconverter import androidx.room.TypeConverter import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.registerTypeAdapter -import com.google.gson.Gson import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.EntityExtra import io.github.sds100.keymapper.data.entities.TriggerEntity @@ -14,17 +13,17 @@ import io.github.sds100.keymapper.data.entities.TriggerKeyEntity */ class TriggerTypeConverter { + private val gson = GsonBuilder() + .registerTypeAdapter(TriggerEntity.DESERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.SERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER) + .registerTypeAdapter(EntityExtra.DESERIALIZER).create() + @TypeConverter fun toTrigger(json: String): TriggerEntity { - val gson = GsonBuilder() - .registerTypeAdapter(TriggerEntity.DESERIALIZER) - .registerTypeAdapter(TriggerKeyEntity.SERIALIZER) - .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER) - .registerTypeAdapter(EntityExtra.DESERIALIZER).create() - return gson.fromJson(json) } @TypeConverter - fun toJsonString(trigger: TriggerEntity) = Gson().toJson(trigger)!! + fun toJsonString(trigger: TriggerEntity) = gson.toJson(trigger)!! } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index 5b60338b0d..f35832c726 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt @@ -1,28 +1,41 @@ package io.github.sds100.keymapper.data.entities +import android.os.Parcelable import com.github.salomonbrys.kotson.byArray +import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import java.util.UUID /** * Created by sds100 on 17/03/2020. */ +@Parcelize data class ConstraintEntity( @SerializedName(NAME_TYPE) val type: String, @SerializedName(NAME_EXTRAS) val extras: List, -) { - constructor(type: String, vararg extra: EntityExtra) : this(type, extra.toList()) + @SerializedName(NAME_UID) + val uid: String, +) : Parcelable { + + constructor(uid: String, type: String, vararg extra: EntityExtra) : this( + uid = uid, + type = type, + extras = extra.toList(), + ) companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. const val NAME_TYPE = "type" const val NAME_EXTRAS = "extras" + const val NAME_UID = "uid" const val MODE_OR = 0 const val MODE_AND = 1 @@ -86,7 +99,14 @@ data class ConstraintEntity( val extrasJsonArray by it.json.byArray(NAME_EXTRAS) val extraList = it.context.deserialize>(extrasJsonArray) ?: listOf() - ConstraintEntity(type, extraList) + // Constraints did not always have UID so this could be null. + val uid by it.json.byNullableString(NAME_UID) + + ConstraintEntity( + uid = uid ?: UUID.randomUUID().toString(), + type = type, + extras = extraList, + ) } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt index 80a0784969..1c1589053e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt @@ -6,9 +6,12 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableFloat import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName +import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_BACKGROUND_OPACITY +import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_BORDER_OPACITY import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_BUTTON_SIZE import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_DISPLAY_HEIGHT import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_DISPLAY_WIDTH @@ -70,6 +73,15 @@ data class FloatingButtonEntity( @ColumnInfo(name = KEY_DISPLAY_HEIGHT) @SerializedName(NAME_DISPLAY_HEIGHT) val displayHeight: Int, + + @ColumnInfo(name = KEY_BORDER_OPACITY) + @SerializedName(NAME_BORDER_OPACITY) + val borderOpacity: Float?, + + @ColumnInfo(name = KEY_BACKGROUND_OPACITY) + @SerializedName(NAME_BACKGROUND_OPACITY) + val backgroundOpacity: Float?, + ) : Parcelable { companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. @@ -82,6 +94,8 @@ data class FloatingButtonEntity( const val NAME_ORIENTATION = "orientation" const val NAME_DISPLAY_WIDTH = "displayWidth" const val NAME_DISPLAY_HEIGHT = "displayHeight" + const val NAME_BORDER_OPACITY = "border_opacity" + const val NAME_BACKGROUND_OPACITY = "background_opacity" val DESERIALIZER = jsonDeserializer { val uid by it.json.byString(NAME_UID) @@ -93,6 +107,8 @@ data class FloatingButtonEntity( val orientation by it.json.byString(NAME_ORIENTATION) val displayWidth by it.json.byInt(NAME_DISPLAY_WIDTH) val displayHeight by it.json.byInt(NAME_DISPLAY_HEIGHT) + val borderOpacity by it.json.byNullableFloat(NAME_BORDER_OPACITY) + val backgroundOpacity by it.json.byNullableFloat(NAME_BACKGROUND_OPACITY) FloatingButtonEntity( uid = uid, @@ -104,6 +120,8 @@ data class FloatingButtonEntity( orientation = orientation, displayWidth = displayWidth, displayHeight = displayHeight, + borderOpacity = borderOpacity, + backgroundOpacity = backgroundOpacity, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt new file mode 100644 index 0000000000..18e97c7f66 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt @@ -0,0 +1,81 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.github.salomonbrys.kotson.byArray +import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableLong +import com.github.salomonbrys.kotson.byNullableString +import com.github.salomonbrys.kotson.byString +import com.github.salomonbrys.kotson.jsonDeserializer +import com.google.gson.annotations.SerializedName +import io.github.sds100.keymapper.data.db.dao.GroupDao +import kotlinx.parcelize.Parcelize +import java.util.UUID + +@Entity( + tableName = GroupDao.TABLE_NAME, + foreignKeys = [ + ForeignKey( + entity = GroupEntity::class, + parentColumns = [GroupDao.KEY_UID], + childColumns = [GroupDao.KEY_PARENT_UID], + onDelete = ForeignKey.CASCADE, + ), + ], +) +@Parcelize +data class GroupEntity( + @PrimaryKey + @ColumnInfo(name = GroupDao.KEY_UID) + @SerializedName(NAME_UID) + val uid: String = UUID.randomUUID().toString(), + + @ColumnInfo(name = GroupDao.KEY_NAME) + @SerializedName(NAME_NAME) + val name: String, + + @ColumnInfo(name = GroupDao.KEY_CONSTRAINTS) + @SerializedName(NAME_CONSTRAINTS) + val constraintList: List = emptyList(), + + @ColumnInfo(name = GroupDao.KEY_CONSTRAINT_MODE) + @SerializedName(NAME_CONSTRAINT_MODE) + val constraintMode: Int = ConstraintEntity.MODE_AND, + + @ColumnInfo(name = GroupDao.KEY_PARENT_UID) + @SerializedName(NAME_PARENT_UID) + val parentUid: String?, + + @ColumnInfo(name = GroupDao.KEY_LAST_OPENED_DATE) + @SerializedName(NAME_LAST_OPENED_DATE) + val lastOpenedDate: Long?, + +) : Parcelable { + companion object { + // DON'T CHANGE THESE. Used for JSON serialization and parsing. + const val NAME_UID = "uid" + const val NAME_NAME = "name" + const val NAME_CONSTRAINTS = "constraints" + const val NAME_CONSTRAINT_MODE = "constraint_mode" + const val NAME_PARENT_UID = "parent_uid" + const val NAME_LAST_OPENED_DATE = "last_opened_date" + + val DESERIALIZER = jsonDeserializer { + val uid by it.json.byString(NAME_UID) + val name by it.json.byString(NAME_NAME) + val constraintListJsonArray by it.json.byArray(NAME_CONSTRAINTS) + val constraintList = + it.context.deserialize>(constraintListJsonArray) + + val constraintMode by it.json.byInt(NAME_CONSTRAINT_MODE) + val parentUid by it.json.byNullableString(NAME_PARENT_UID) + val lastOpenedDate by it.json.byNullableLong(NAME_LAST_OPENED_DATE) + + GroupEntity(uid, name, constraintList, constraintMode, parentUid, lastOpenedDate) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithChildren.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithChildren.kt new file mode 100644 index 0000000000..ecedbaa99b --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithChildren.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.data.entities + +import androidx.room.Embedded +import androidx.room.Relation +import io.github.sds100.keymapper.data.db.dao.GroupDao + +data class GroupEntityWithChildren( + @Embedded + val group: GroupEntity, + + @Relation( + parentColumn = GroupDao.KEY_UID, + entityColumn = GroupDao.KEY_PARENT_UID, + ) + val children: List, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt new file mode 100644 index 0000000000..7c6a12a082 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt @@ -0,0 +1,20 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import androidx.room.Embedded +import androidx.room.Relation +import io.github.sds100.keymapper.data.db.dao.GroupDao +import io.github.sds100.keymapper.data.db.dao.KeyMapDao +import kotlinx.parcelize.Parcelize + +@Parcelize +data class KeyMapEntitiesWithGroup( + @Embedded + val group: GroupEntity, + + @Relation( + parentColumn = GroupDao.KEY_UID, + entityColumn = KeyMapDao.KEY_GROUP_UID, + ) + val keyMaps: List, +) : Parcelable diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt index 1a79a3b022..bb9dda5088 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt @@ -1,7 +1,9 @@ package io.github.sds100.keymapper.data.entities +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import com.github.salomonbrys.kotson.byArray @@ -12,7 +14,9 @@ import com.github.salomonbrys.kotson.byObject import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName +import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.db.dao.KeyMapDao +import kotlinx.parcelize.Parcelize import java.util.UUID /** @@ -22,7 +26,16 @@ import java.util.UUID @Entity( tableName = KeyMapDao.TABLE_NAME, indices = [Index(value = [KeyMapDao.KEY_UID], unique = true)], + foreignKeys = [ + ForeignKey( + entity = GroupEntity::class, + parentColumns = [GroupDao.KEY_UID], + childColumns = [KeyMapDao.KEY_GROUP_UID], + onDelete = ForeignKey.CASCADE, + ), + ], ) +@Parcelize data class KeyMapEntity( @SerializedName(NAME_ID) @PrimaryKey(autoGenerate = true) @@ -51,10 +64,6 @@ data class KeyMapEntity( @ColumnInfo(name = KeyMapDao.KEY_FLAGS) val flags: Int = 0, - @SerializedName(NAME_FOLDER_NAME) - @ColumnInfo(name = KeyMapDao.KEY_FOLDER_NAME) - val folderName: String? = null, - @SerializedName(NAME_IS_ENABLED) @ColumnInfo(name = KeyMapDao.KEY_ENABLED) val isEnabled: Boolean = true, @@ -62,7 +71,11 @@ data class KeyMapEntity( @SerializedName(NAME_UID) @ColumnInfo(name = KeyMapDao.KEY_UID) val uid: String = UUID.randomUUID().toString(), -) { + + @SerializedName(NAME_GROUP_UID) + @ColumnInfo(name = KeyMapDao.KEY_GROUP_UID) + val groupUid: String? = null, +) : Parcelable { companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. @@ -72,9 +85,9 @@ data class KeyMapEntity( const val NAME_CONSTRAINT_LIST = "constraintList" const val NAME_CONSTRAINT_MODE = "constraintMode" const val NAME_FLAGS = "flags" - const val NAME_FOLDER_NAME = "folderName" const val NAME_IS_ENABLED = "isEnabled" const val NAME_UID = "uid" + const val NAME_GROUP_UID = "group_uid" val DESERIALIZER = jsonDeserializer { val actionListJsonArray by it.json.byArray(NAME_ACTION_LIST) @@ -89,20 +102,20 @@ data class KeyMapEntity( val constraintMode by it.json.byInt(NAME_CONSTRAINT_MODE) val flags by it.json.byInt(NAME_FLAGS) - val folderName by it.json.byNullableString(NAME_FOLDER_NAME) val isEnabled by it.json.byBool(NAME_IS_ENABLED) val uid by it.json.byString(NAME_UID) { UUID.randomUUID().toString() } + val groupUid by it.json.byNullableString(NAME_GROUP_UID) KeyMapEntity( - 0, - trigger, - actionList, - constraintList, - constraintMode, - flags, - folderName, - isEnabled, - uid, + id = 0, + trigger = trigger, + actionList = actionList, + constraintList = constraintList, + constraintMode = constraintMode, + flags = flags, + isEnabled = isEnabled, + uid = uid, + groupUid = groupUid, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt new file mode 100644 index 0000000000..b823941134 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration14To15 : AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration15To16.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration15To16.kt new file mode 100644 index 0000000000..f8d172419a --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration15To16.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.DeleteColumn +import androidx.room.migration.AutoMigrationSpec + +@DeleteColumn("keymaps", "folder_name") +class AutoMigration15To16 : AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration16To17.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration16To17.kt new file mode 100644 index 0000000000..ce92b9a3a9 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration16To17.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration16To17 : AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt index 8817218450..50080c1290 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt @@ -46,6 +46,8 @@ class RoomFloatingButtonRepository( layoutUid = button.layoutUid, text = button.text, buttonSize = button.buttonSize, + borderOpacity = button.borderOpacity, + backgroundOpacity = button.backgroundOpacity, x = button.x, y = button.y, orientation = button.orientation, diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt new file mode 100644 index 0000000000..23423e11b8 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt @@ -0,0 +1,91 @@ +package io.github.sds100.keymapper.data.repositories + +import io.github.sds100.keymapper.data.db.dao.GroupDao +import io.github.sds100.keymapper.data.entities.GroupEntity +import io.github.sds100.keymapper.data.entities.GroupEntityWithChildren +import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup +import io.github.sds100.keymapper.util.DefaultDispatcherProvider +import io.github.sds100.keymapper.util.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +interface GroupRepository { + val groups: Flow> + + fun getKeyMapsByGroup(groupUid: String): Flow + suspend fun getGroup(uid: String): GroupEntity? + fun getAllGroups(): Flow> + fun getGroups(vararg uid: String): Flow> + fun getGroupsByParent(uid: String?): Flow> + fun getGroupWithChildren(uid: String): Flow + suspend fun insert(groupEntity: GroupEntity) + suspend fun update(groupEntity: GroupEntity) + fun delete(uid: String) + suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) +} + +class RoomGroupRepository( + private val dao: GroupDao, + private val coroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), +) : GroupRepository { + + override val groups: StateFlow> = + dao.getAll().stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + override fun getKeyMapsByGroup(groupUid: String): Flow { + return dao.getKeyMapsByGroup(groupUid).flowOn(dispatchers.io()) + } + + override suspend fun getGroup(uid: String): GroupEntity? { + return withContext(dispatchers.io()) { dao.getById(uid) } + } + + override fun getAllGroups(): Flow> { + return dao.getAll().flowOn(dispatchers.io()) + } + + override fun getGroups(vararg uid: String): Flow> { + return dao.getManyByIdFlow(*uid).flowOn(dispatchers.io()) + } + + override fun getGroupsByParent(uid: String?): Flow> { + return dao.getGroupsByParent(uid).flowOn(dispatchers.io()) + } + + override fun getGroupWithChildren(uid: String): Flow { + return dao.getGroupWithSubGroups(uid).flowOn(dispatchers.io()) + } + + override suspend fun insert(groupEntity: GroupEntity) { + withContext(dispatchers.io()) { + dao.insert(groupEntity) + } + } + + override suspend fun update(groupEntity: GroupEntity) { + withContext(dispatchers.io()) { + dao.update(groupEntity) + } + } + + override fun delete(uid: String) { + coroutineScope.launch { + withContext(dispatchers.io()) { + dao.deleteByUid(uid) + } + } + } + + override suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) { + withContext(dispatchers.io()) { + dao.setLastOpenedDate(groupUid, timestamp) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RepositoryUtils.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RepositoryUtils.kt new file mode 100644 index 0000000000..50ad468cc5 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RepositoryUtils.kt @@ -0,0 +1,27 @@ +package io.github.sds100.keymapper.data.repositories + +object RepositoryUtils { + suspend fun saveUniqueName( + entity: T, + saveBlock: suspend (entity: T) -> Unit, + renameBlock: (entity: T, suffix: String) -> T, + ): T { + var group = entity + var count = 0 + + while (count < 1000) { + // Insert must be suspending so we only update the layout uid once the layout + // has been saved. + try { + saveBlock(group) + break + } catch (_: Exception) { + // If the name already exists try creating it with a new name. + group = renameBlock(entity, "(${count + 1})") + count++ + } + } + + return group + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index 438776959d..2a0f1737a2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -11,7 +11,7 @@ import io.github.sds100.keymapper.util.DispatcherProvider import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.splitIntoBatches import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn @@ -37,23 +37,25 @@ class RoomKeyMapRepository( .flowOn(dispatchers.io()) .stateIn(coroutineScope, SharingStarted.Eagerly, State.Loading) - override val requestBackup = MutableSharedFlow>() - init { coroutineScope.launch { migrateFingerprintMaps() - - requestBackup() } } + override fun getAll(): Flow> { + return keyMapDao.getAll().flowOn(dispatchers.io()) + } + + override fun getByGroup(groupUid: String?): Flow> { + return keyMapDao.getByGroup(groupUid).flowOn(dispatchers.io()) + } + override fun insert(vararg keyMap: KeyMapEntity) { coroutineScope.launch(dispatchers.io()) { - keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { + for (it in keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.insert(*it) } - - requestBackup() } } @@ -65,11 +67,9 @@ class RoomKeyMapRepository( override fun update(vararg keyMap: KeyMapEntity) { coroutineScope.launch(dispatchers.io()) { - keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { + for (it in keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.update(*it) } - - requestBackup() } } @@ -77,17 +77,15 @@ class RoomKeyMapRepository( override fun delete(vararg uid: String) { coroutineScope.launch(dispatchers.io()) { - uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { + for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.deleteById(*it) } - - requestBackup() } } override fun duplicate(vararg uid: String) { coroutineScope.launch(dispatchers.io()) { - uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { uidBatch -> + for (uidBatch in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { val keymaps = mutableListOf() for (keyMapUid in uidBatch) { @@ -97,28 +95,30 @@ class RoomKeyMapRepository( keyMapDao.insert(*keymaps.toTypedArray()) } - - requestBackup() } } override fun enableById(vararg uid: String) { coroutineScope.launch(dispatchers.io()) { - uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { + for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.enableKeyMapByUid(*it) } - - requestBackup() } } override fun disableById(vararg uid: String) { coroutineScope.launch(dispatchers.io()) { - uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { + for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.disableKeyMapByUid(*it) } + } + } - requestBackup() + override fun moveToGroup(groupUid: String?, vararg uid: String) { + coroutineScope.launch { + for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { + keyMapDao.setKeyMapGroup(groupUid, *it) + } } } @@ -134,11 +134,4 @@ class RoomKeyMapRepository( fingerprintMapDao.update(migratedFingerprintMapEntity) } } - - private fun requestBackup() { - coroutineScope.launch { - val keyMapList = keyMapList.first { it is State.Data } as State.Data - requestBackup.emit(keyMapList.data) - } - } } diff --git a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt index 8e4f5f6abd..973025ca96 100644 --- a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt +++ b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt @@ -6,10 +6,14 @@ import kotlinx.serialization.Serializable data class FloatingButtonAppearance( val text: String = "", val size: Int = DEFAULT_SIZE_DP, + val borderOpacity: Float = DEFAULT_BORDER_OPACITY, + val backgroundOpacity: Float = DEFAULT_BACKGROUND_OPACITY, ) { companion object { const val MIN_SIZE_DP: Int = 20 const val DEFAULT_SIZE_DP: Int = 40 const val MAX_SIZE_DP: Int = 120 + const val DEFAULT_BACKGROUND_OPACITY = 0.5f + const val DEFAULT_BORDER_OPACITY = 1f } } diff --git a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt index 4de1da4ec9..f8a19fe376 100644 --- a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt +++ b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt @@ -45,6 +45,8 @@ object FloatingButtonEntityMapper { return entity.copy( text = appearance.text, buttonSize = appearance.size, + borderOpacity = appearance.borderOpacity, + backgroundOpacity = appearance.backgroundOpacity, ) } @@ -66,6 +68,10 @@ object FloatingButtonEntityMapper { appearance = FloatingButtonAppearance( text = entity.text, size = entity.buttonSize, + borderOpacity = entity.borderOpacity + ?: FloatingButtonAppearance.DEFAULT_BORDER_OPACITY, + backgroundOpacity = entity.backgroundOpacity + ?: FloatingButtonAppearance.DEFAULT_BACKGROUND_OPACITY, ), location = Location( x = entity.x, @@ -82,6 +88,8 @@ object FloatingButtonEntityMapper { layoutUid = button.layoutUid, text = button.appearance.text, buttonSize = button.appearance.size, + borderOpacity = button.appearance.borderOpacity, + backgroundOpacity = button.appearance.backgroundOpacity, x = button.location.x, y = button.location.y, orientation = ConstantTypeConverters.ORIENTATION_MAP[button.location.orientation]!!, diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt b/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt new file mode 100644 index 0000000000..29155760f1 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt @@ -0,0 +1,50 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import io.github.sds100.keymapper.R + +@Composable +fun DeleteGroupDialog( + modifier: Modifier = Modifier, + groupName: String, + onDismissRequest: () -> Unit, + onDeleteClick: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text( + stringResource( + R.string.home_key_maps_delete_group_dialog_title, + groupName, + ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + }, + text = { + Text( + stringResource(R.string.home_key_maps_delete_group_dialog_text), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDeleteClick) { + Text(stringResource(R.string.home_key_maps_delete_group_yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.home_key_maps_delete_group_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt b/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt new file mode 100644 index 0000000000..6633399388 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt @@ -0,0 +1,44 @@ +package io.github.sds100.keymapper.groups + +import io.github.sds100.keymapper.constraints.ConstraintEntityMapper +import io.github.sds100.keymapper.constraints.ConstraintModeEntityMapper +import io.github.sds100.keymapper.constraints.ConstraintState +import io.github.sds100.keymapper.data.entities.GroupEntity + +data class Group( + val uid: String, + val name: String, + val constraintState: ConstraintState, + val parentUid: String?, + val lastOpenedDate: Long, +) + +object GroupEntityMapper { + fun fromEntity(entity: GroupEntity): Group { + val constraintList = + entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet() + + val constraintMode = ConstraintModeEntityMapper.fromEntity(entity.constraintMode) + + return Group( + uid = entity.uid, + name = entity.name, + constraintState = ConstraintState(constraintList, constraintMode), + parentUid = entity.parentUid, + lastOpenedDate = entity.lastOpenedDate ?: System.currentTimeMillis(), + ) + } + + fun toEntity(group: Group): GroupEntity { + return GroupEntity( + uid = group.uid, + name = group.name, + constraintList = group.constraintState.constraints.map { + ConstraintEntityMapper.toEntity(it) + }, + constraintMode = ConstraintModeEntityMapper.toEntity(group.constraintState.mode), + parentUid = group.parentUid, + lastOpenedDate = group.lastOpenedDate, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt new file mode 100644 index 0000000000..70ff333357 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt @@ -0,0 +1,109 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R + +@Composable +fun GroupBreadcrumbRow( + modifier: Modifier = Modifier, + groups: List, + onGroupClick: (String?) -> Unit, + enabled: Boolean = true, +) { + val scrollState = rememberScrollState() + + LaunchedEffect(groups) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + BoxWithConstraints(modifier = modifier) { + val maxCrumbWidth = constraints.maxWidth / 3 + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + + ) { + val color = LocalContentColor.current.copy(alpha = 0.7f) + Breadcrumb( + text = stringResource(R.string.home_groups_breadcrumb_home), + onClick = { onGroupClick(null) }, + color = color, + enabled = enabled, + ) + + for ((index, group) in groups.withIndex()) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + null, + tint = color, + ) + + Breadcrumb( + modifier = Modifier.widthIn(max = LocalDensity.current.run { maxCrumbWidth.toDp() }), + text = group.name, + onClick = { onGroupClick(group.uid) }, + color = if (index == groups.lastIndex) { + LocalContentColor.current + } else { + color + }, + enabled = enabled, + ) + } + } + } +} + +@Composable +private fun Breadcrumb( + modifier: Modifier = Modifier, + text: String, + color: Color, + onClick: () -> Unit, + enabled: Boolean, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.small, + color = Color.Transparent, + enabled = enabled, + ) { + Text( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + text = text, + style = MaterialTheme.typography.labelMedium, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt new file mode 100644 index 0000000000..6ba5aeb4ef --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -0,0 +1,370 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import io.github.sds100.keymapper.Constants +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo + +@Composable +fun GroupConstraintRow( + modifier: Modifier = Modifier, + constraints: List, + mode: ConstraintMode, + parentConstraintCount: Int, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onFixConstraintClick: (Error) -> Unit = {}, + enabled: Boolean = true, +) { + BoxWithConstraints(modifier = modifier) { + val maxChipWidth = LocalDensity.current.run { + (this@BoxWithConstraints.constraints.maxWidth / 2).toDp() + } + + FlowRow( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + for ((index, constraint) in constraints.withIndex()) { + when (constraint) { + is ComposeChipModel.Normal -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + ConstraintButton( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, + icon = { + if (constraint.icon is ComposeIconInfo.Vector) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = constraint.icon.imageVector, + contentDescription = null, + ) + } else if (constraint.icon is ComposeIconInfo.Drawable) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter(constraint.icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + ) + } + + is ComposeChipModel.Error -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer) { + ConstraintErrorButton( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + onClick = { onFixConstraintClick(constraint.error) }, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, + ) + } + } + + if (index < constraints.lastIndex) { + when (mode) { + ConstraintMode.AND -> Text( + text = stringResource(R.string.constraint_mode_and), + style = MaterialTheme.typography.labelMedium, + ) + + ConstraintMode.OR -> Text( + text = stringResource(R.string.constraint_mode_or), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + + if (parentConstraintCount > 0) { + Text( + modifier = Modifier + .padding(horizontal = 8.dp), + text = pluralStringResource( + R.plurals.home_groups_inherited_constraints, + parentConstraintCount, + parentConstraintCount, + ), + style = MaterialTheme.typography.labelMedium, + ) + } + + NewConstraintButton( + onClick = onNewConstraintClick, + showText = constraints.isEmpty(), + enabled = enabled, + ) + } + } +} + +@Composable +private fun NewConstraintButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + showText: Boolean = true, + enabled: Boolean, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(0.2f)), + color = Color.Transparent, + enabled = enabled, + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.home_group_new_constraint_button), + ) + + if (showText) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.home_group_new_constraint_button), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun ConstraintButton( + modifier: Modifier = Modifier, + text: String, + icon: @Composable () -> Unit, + onRemoveClick: () -> Unit = {}, + enabled: Boolean, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), + ) { + Row( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .heightIn(min = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + + Text( + modifier = Modifier.weight(1f, fill = false), + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + modifier = Modifier.size(16.dp), + onClick = onRemoveClick, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.home_group_delete_constraint_button), + ) + } + } + } + } +} + +@Composable +private fun ConstraintErrorButton( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, + onRemoveClick: () -> Unit = {}, + enabled: Boolean, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + onClick = onClick, + enabled = enabled, + ) { + Row( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .heightIn(min = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + ) + + Text( + modifier = Modifier.weight(1f, fill = false), + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + modifier = Modifier.size(16.dp), + onClick = onRemoveClick, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.home_group_delete_constraint_button), + ) + } + } + } + } +} + +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + Surface { + GroupConstraintRow( + constraints = emptyList(), + mode = ConstraintMode.AND, + parentConstraintCount = 0, + ) + } + } +} + +@Preview +@Composable +private fun PreviewOneItem() { + KeyMapperTheme { + Surface { + GroupConstraintRow( + constraints = listOf( + ComposeChipModel.Normal( + id = "1", + text = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ), + mode = ConstraintMode.OR, + parentConstraintCount = 1, + ) + } + } +} + +@Preview +@Composable +private fun PreviewMultipleItems() { + val ctx = LocalContext.current + + KeyMapperTheme { + Surface { + GroupConstraintRow( + constraints = listOf( + ComposeChipModel.Normal( + id = "1", + text = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ComposeChipModel.Normal( + id = "2", + text = "Key Mapper is open", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ComposeChipModel.Normal( + id = "2", + text = "Key Mapper is open", + icon = null, + ), + ComposeChipModel.Error( + id = "2", + text = "Key Mapper not found", + error = Error.AppNotFound(Constants.PACKAGE_NAME), + ), + ), + mode = ConstraintMode.AND, + parentConstraintCount = 3, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt new file mode 100644 index 0000000000..e6e7a790e4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.groups + +data class GroupFamily( + val group: Group?, + val children: List, + val parents: List, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupListItemModel.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupListItemModel.kt new file mode 100644 index 0000000000..9f53a1da74 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupListItemModel.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.groups + +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo + +data class GroupListItemModel(val uid: String, val name: String, val icon: ComposeIconInfo? = null) diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt new file mode 100644 index 0000000000..a9f112206a --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -0,0 +1,388 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowOverflow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo + +@Composable +fun GroupRow( + modifier: Modifier = Modifier, + groups: List, + onNewGroupClick: () -> Unit = {}, + onGroupClick: (String) -> Unit = {}, + enabled: Boolean = true, + isSubgroups: Boolean = false, + showThisGroupButton: Boolean = false, + onThisGroupClick: () -> Unit = {}, +) { + var viewAllState by rememberSaveable { mutableStateOf(false) } + + BoxWithConstraints(modifier = modifier) { + val maxChipWidth = constraints.maxWidth / 2 + + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + maxLines = if (viewAllState) { + Int.MAX_VALUE + } else { + 2 + }, + overflow = FlowRowOverflow.expandOrCollapseIndicator( + expandIndicator = { + // Show new group button in the expand indicator if the new group button + // in the flow row has overflowed. + Row { + NewGroupButton( + onClick = onNewGroupClick, + text = if (isSubgroups) { + stringResource(R.string.home_new_subgroup_button) + } else { + stringResource(R.string.home_new_group_button) + }, + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + enabled = enabled, + ) + + Spacer(Modifier.width(8.dp)) + + // Some padding is required on the end to stop it overflowing the screen. + TextGroupButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { viewAllState = true }, + text = stringResource(R.string.home_new_view_all_groups_button), + enabled = enabled, + ) + } + }, + collapseIndicator = { + // Some padding is required on the end to stop it overflowing the screen. + TextGroupButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { viewAllState = false }, + text = stringResource(R.string.home_new_hide_groups_button), + enabled = enabled, + ) + }, + minRowsToShowCollapse = 3, + ), + ) { + if (showThisGroupButton) { + TextGroupButton( + onClick = onThisGroupClick, + text = stringResource(R.string.home_this_group_button), + enabled = enabled, + ) + } + + for (group in groups) { + GroupButton( + modifier = Modifier.widthIn(max = LocalDensity.current.run { maxChipWidth.toDp() }), + onClick = { onGroupClick(group.uid) }, + text = group.name, + enabled = enabled, + icon = { + when (group.icon) { + is ComposeIconInfo.Drawable -> { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter(group.icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + } + + is ComposeIconInfo.Vector -> { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = group.icon.imageVector, + contentDescription = null, + ) + } + + null -> {} + } + }, + ) + } + + NewGroupButton( + onClick = onNewGroupClick, + text = if (isSubgroups) { + stringResource(R.string.home_new_subgroup_button) + } else { + stringResource(R.string.home_new_group_button) + }, + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + enabled = enabled, + ) + } + } +} + +@Composable +private fun NewGroupButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String, + icon: @Composable () -> Unit, + showText: Boolean = true, + enabled: Boolean, +) { + val color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + CompositionLocalProvider( + LocalContentColor provides color, + ) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, color = color), + color = Color.Transparent, + enabled = enabled, + ) { + Row( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + + if (showText) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + } + } + } + } +} + +@Composable +private fun TextGroupButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String, + enabled: Boolean, +) { + val color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + CompositionLocalProvider( + LocalContentColor provides color, + ) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, color), + color = Color.Transparent, + enabled = enabled, + ) { + Row( + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .heightIn(min = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent(text) { text -> + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + } + } + } + } +} + +@Composable +private fun GroupButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String, + icon: @Composable () -> Unit, + enabled: Boolean, +) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), + enabled = enabled, + ) { + Row( + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .heightIn(min = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + Surface { + GroupRow(groups = emptyList()) + } + } +} + +@Preview +@Composable +private fun PreviewEmptyDisabled() { + KeyMapperTheme { + Surface { + GroupRow(groups = emptyList(), enabled = false) + } + } +} + +@Preview +@Composable +private fun PreviewOneItem() { + KeyMapperTheme { + Surface { + GroupRow( + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ), + enabled = false, + showThisGroupButton = false, + ) + } + } +} + +@Preview +@Composable +private fun PreviewMultipleItems() { + val ctx = LocalContext.current + + KeyMapperTheme { + Surface { + GroupRow( + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "2", + name = "Lockscreen", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "2", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + ), + showThisGroupButton = true, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/DeleteKeyMapsDialog.kt b/app/src/main/java/io/github/sds100/keymapper/home/DeleteKeyMapsDialog.kt new file mode 100644 index 0000000000..70d7772c34 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/DeleteKeyMapsDialog.kt @@ -0,0 +1,49 @@ +package io.github.sds100.keymapper.home + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.github.sds100.keymapper.R + +@Composable +fun DeleteKeyMapsDialog( + modifier: Modifier = Modifier, + keyMapCount: Int, + onDismissRequest: () -> Unit, + onDeleteClick: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text( + pluralStringResource( + R.plurals.home_key_maps_delete_dialog_title, + keyMapCount, + keyMapCount, + ), + ) + }, + text = { + Text( + stringResource(R.string.home_key_maps_delete_dialog_text, keyMapCount), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDeleteClick) { + Text(stringResource(R.string.home_key_maps_delete_yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.home_key_maps_delete_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt new file mode 100644 index 0000000000..453c9caed9 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -0,0 +1,688 @@ +package io.github.sds100.keymapper.home + +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.FlashlightOn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.backup.ImportExportState +import io.github.sds100.keymapper.backup.RestoreType +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.groups.GroupListItemModel +import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState +import io.github.sds100.keymapper.mappings.keymaps.KeyMapList +import io.github.sds100.keymapper.mappings.keymaps.KeyMapListViewModel +import io.github.sds100.keymapper.mappings.keymaps.trigger.DpadTriggerSetupBottomSheet +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError +import io.github.sds100.keymapper.sorting.SortBottomSheet +import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.ShareUtils +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.CollapsableFloatingActionButton +import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeKeyMapListScreen( + modifier: Modifier = Modifier, + viewModel: KeyMapListViewModel, + snackbarState: SnackbarHostState, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + finishActivity: () -> Unit, + fabBottomPadding: Dp, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val setupGuiKeyboardState by viewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() + + val importFileLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri ?: return@rememberLauncherForActivityResult + + viewModel.onChooseImportFile(uri.toString()) + } + + val importExportState by viewModel.importExportState.collectAsStateWithLifecycle() + + HandleImportExportState( + state = importExportState, + snackbarState = snackbarState, + setIdleState = viewModel::setImportExportIdle, + onConfirmImport = viewModel::onConfirmImport, + ) + + if (viewModel.showDpadTriggerSetupBottomSheet) { + DpadTriggerSetupBottomSheet( + modifier = Modifier.systemBarsPadding(), + onDismissRequest = { + viewModel.showDpadTriggerSetupBottomSheet = false + }, + guiKeyboardState = setupGuiKeyboardState, + onEnableKeyboardClick = viewModel::onEnableGuiKeyboardClick, + onChooseKeyboardClick = viewModel::onChooseGuiKeyboardClick, + onNeverShowAgainClick = viewModel::onNeverShowSetupDpadClick, + sheetState = sheetState, + ) + } + + if (viewModel.showSortBottomSheet) { + SortBottomSheet( + viewModel = viewModel.sortViewModel, + onDismissRequest = { viewModel.showSortBottomSheet = false }, + sheetState = sheetState, + ) + } + + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + + if (showDeleteDialog && state.appBarState is KeyMapAppBarState.Selecting) { + val keyMapCount = (state.appBarState as KeyMapAppBarState.Selecting).selectionCount + + DeleteKeyMapsDialog( + keyMapCount = keyMapCount, + onDismissRequest = { showDeleteDialog = false }, + onDeleteClick = { + viewModel.onDeleteSelectedKeyMapsClick() + showDeleteDialog = false + }, + ) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val uriHandler = LocalUriHandler.current + val helpUrl = stringResource(R.string.url_quick_start_guide) + + var keyMapListBottomPadding by remember { mutableStateOf(100.dp) } + + HomeKeyMapListScreen( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarState = snackbarState, + floatingActionButton = { + AnimatedVisibility( + state.appBarState !is KeyMapAppBarState.Selecting, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }), + ) { + CollapsableFloatingActionButton( + modifier = Modifier.padding(bottom = fabBottomPadding), + onClick = viewModel::onNewKeyMapClick, + showText = viewModel.showFabText, + text = stringResource(R.string.home_fab_new_key_map), + ) + } + }, + listContent = { + KeyMapList( + modifier = Modifier.animateContentSize(), + lazyListState = rememberLazyListState(), + listItems = state.listItems, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = state.appBarState is KeyMapAppBarState.Selecting, + onClickKeyMap = viewModel::onKeyMapCardClick, + onLongClickKeyMap = viewModel::onKeyMapCardLongClick, + onSelectedChange = viewModel::onKeyMapSelectedChanged, + onFixClick = viewModel::onFixClick, + onTriggerErrorClick = viewModel::onFixTriggerError, + bottomListPadding = keyMapListBottomPadding, + ) + }, + appBarContent = { + KeyMapAppBar( + state = state.appBarState, + scrollBehavior = scrollBehavior, + onSettingsClick = onSettingsClick, + onAboutClick = onAboutClick, + onSortClick = { viewModel.showSortBottomSheet = true }, + onHelpClick = { uriHandler.openUri(helpUrl) }, + onExportClick = viewModel::onExportClick, + onImportClick = { importFileLauncher.launch(FileUtils.MIME_TYPE_ALL) }, + onTogglePausedClick = viewModel::onTogglePausedClick, + onFixWarningClick = viewModel::onFixWarningClick, + onBackClick = { + if (!viewModel.onBackClick()) { + finishActivity() + } + }, + onSelectAllClick = viewModel::onSelectAllClick, + onNewGroupClick = viewModel::onNewGroupClick, + onRenameGroupClick = viewModel::onRenameGroupClick, + onEditGroupNameClick = viewModel::onEditGroupNameClick, + onGroupClick = viewModel::onGroupClick, + onDeleteGroupClick = viewModel::onDeleteGroupClick, + onNewConstraintClick = viewModel::onNewGroupConstraintClick, + onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, + onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, + onFixConstraintClick = viewModel::onFixClick, + ) + }, + selectionBottomSheet = { + AnimatedVisibility( + visible = state.appBarState is KeyMapAppBarState.Selecting, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + val selectionState = (state.appBarState as? KeyMapAppBarState.Selecting) + ?: KeyMapAppBarState.Selecting( + selectionCount = 0, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, + isAllSelected = false, + groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, + ) + + SelectionBottomSheet( + modifier = Modifier.onSizeChanged { size -> + keyMapListBottomPadding = + ((size.height.dp / 2) - 100.dp).coerceAtLeast(0.dp) + }, + enabled = selectionState.selectionCount > 0, + groups = selectionState.groups, + breadcrumbs = selectionState.breadcrumbs, + selectedKeyMapsEnabled = selectionState.selectedKeyMapsEnabled, + onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, + onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, + onExportClick = viewModel::onExportSelectedKeyMaps, + onDeleteClick = { showDeleteDialog = true }, + onGroupClick = viewModel::onSelectionGroupClick, + onNewGroupClick = viewModel::onNewGroupClick, + showThisGroup = selectionState.showThisGroup, + onThisGroupClick = viewModel::onMoveToThisGroupClick, + ) + } + }, + ) +} + +@Composable +private fun HomeKeyMapListScreen( + modifier: Modifier = Modifier, + snackbarState: SnackbarHostState = SnackbarHostState(), + appBarContent: @Composable () -> Unit, + listContent: @Composable () -> Unit, + floatingActionButton: @Composable () -> Unit, + selectionBottomSheet: @Composable () -> Unit, +) { + Scaffold( + modifier, + snackbarHost = { SnackbarHost(hostState = snackbarState) }, + topBar = appBarContent, + floatingActionButton = floatingActionButton, + ) { padding -> + Surface(modifier = Modifier.padding(padding)) { + Box(contentAlignment = Alignment.BottomCenter) { + listContent() + selectionBottomSheet() + } + } + } +} + +@Composable +fun HandleImportExportState( + state: ImportExportState, + snackbarState: SnackbarHostState, + setIdleState: () -> Unit, + onConfirmImport: (RestoreType) -> Unit, +) { + when (state) { + is ImportExportState.Error -> { + val text = stringResource(R.string.home_export_error_snackbar, state.error) + LaunchedEffect(state) { + snackbarState.currentSnackbarData?.dismiss() + snackbarState.showSnackbar(text, duration = SnackbarDuration.Short) + setIdleState() + } + } + + ImportExportState.Exporting -> { + val text = stringResource(R.string.home_exporting_snackbar) + LaunchedEffect(state) { + snackbarState.showSnackbar(text, duration = SnackbarDuration.Indefinite) + } + } + + ImportExportState.Importing -> { + val text = stringResource(R.string.home_importing_snackbar) + LaunchedEffect(state) { + snackbarState.showSnackbar(text, duration = SnackbarDuration.Indefinite) + } + } + + is ImportExportState.FinishedExport -> { + snackbarState.currentSnackbarData?.dismiss() + LocalActivity.current?.let { ShareUtils.shareFile(it, state.uri.toUri()) } + setIdleState() + } + + is ImportExportState.FinishedImport -> { + val text = stringResource(R.string.home_importing_finished_snackbar) + LaunchedEffect(state) { + snackbarState.currentSnackbarData?.dismiss() + snackbarState.showSnackbar(text, duration = SnackbarDuration.Short) + setIdleState() + } + } + + ImportExportState.Idle -> { + snackbarState.currentSnackbarData?.dismiss() + } + + is ImportExportState.ConfirmImport -> { + snackbarState.currentSnackbarData?.dismiss() + ImportDialog( + keyMapCount = state.keyMapCount, + onDismissRequest = setIdleState, + onAppendClick = { onConfirmImport(RestoreType.APPEND) }, + onReplaceClick = { onConfirmImport(RestoreType.REPLACE) }, + ) + } + } +} + +@Composable +private fun sampleList(): List { + val context = LocalContext.current + + return listOf( + KeyMapListItemModel( + isSelected = true, + KeyMapListItemModel.Content( + uid = "0", + triggerKeys = listOf("Volume down", "Volume up", "Volume down"), + triggerSeparatorIcon = Icons.AutoMirrored.Outlined.ArrowForward, + actions = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Open Key Mapper", + ), + ComposeChipModel.Error( + id = "1", + text = "Input KEYCODE_0 • Repeat until released", + error = Error.NoCompatibleImeChosen, + ), + ComposeChipModel.Normal( + id = "2", + text = "Input KEYCODE_Q", + icon = null, + ), + ComposeChipModel.Normal( + id = "3", + text = "Toggle flashlight", + icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), + ), + ), + constraintMode = ConstraintMode.AND, + constraints = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Key Mapper is not open", + ), + ComposeChipModel.Error( + id = "1", + "Key Mapper is playing media", + error = Error.AppNotFound(""), + ), + ), + options = listOf("Vibrate"), + triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), + extraInfo = "Disabled • No trigger", + ), + ), + KeyMapListItemModel( + isSelected = true, + KeyMapListItemModel.Content( + uid = "1", + triggerKeys = listOf("Volume down", "Volume up"), + triggerSeparatorIcon = Icons.Outlined.Add, + actions = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Open Key Mapper", + ), + ), + constraintMode = ConstraintMode.AND, + constraints = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Key Mapper is not open", + ), + ), + options = listOf( + "Vibrate", + "Vibrate when keys are initially pressed and again when long pressed", + ), + triggerErrors = emptyList(), + extraInfo = null, + ), + ), + KeyMapListItemModel( + isSelected = true, + KeyMapListItemModel.Content( + uid = "2", + triggerKeys = listOf("Volume down", "Volume up"), + triggerSeparatorIcon = Icons.Outlined.Add, + actions = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Open Key Mapper", + ), + ), + constraintMode = ConstraintMode.AND, + constraints = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Key Mapper is not open", + ), + ), + options = emptyList(), + triggerErrors = emptyList(), + extraInfo = null, + ), + ), + KeyMapListItemModel( + isSelected = true, + KeyMapListItemModel.Content( + uid = "3", + triggerKeys = listOf("Volume down", "Volume up"), + triggerSeparatorIcon = Icons.Outlined.Add, + actions = listOf( + ComposeChipModel.Normal( + id = "0", + ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + "Open Key Mapper", + ), + ), + constraintMode = ConstraintMode.AND, + constraints = emptyList(), + options = emptyList(), + triggerErrors = emptyList(), + extraInfo = null, + ), + ), + KeyMapListItemModel( + isSelected = false, + content = KeyMapListItemModel.Content( + uid = "4", + triggerKeys = emptyList(), + triggerSeparatorIcon = Icons.Outlined.Add, + actions = emptyList(), + constraintMode = ConstraintMode.OR, + constraints = emptyList(), + options = emptyList(), + triggerErrors = emptyList(), + extraInfo = "Disabled • No trigger", + ), + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewSelectingKeyMaps() { + val appBarState = KeyMapAppBarState.Selecting( + selectionCount = 2, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + isAllSelected = false, + groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, + ) + + val listState = State.Data(sampleList()) + + KeyMapperTheme { + HomeKeyMapListScreen( + floatingActionButton = {}, + listContent = { + KeyMapList( + lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = 4), + listItems = listState, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = true, + ) + }, + appBarContent = { + KeyMapAppBar(state = appBarState) + }, + selectionBottomSheet = { + SelectionBottomSheet( + enabled = true, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + groups = emptyList(), + breadcrumbs = emptyList(), + ) + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewKeyMapsRunning() { + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = false, + ) + + val listState = State.Data(sampleList()) + + KeyMapperTheme { + HomeKeyMapListScreen( + floatingActionButton = { + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) + }, + listContent = { + KeyMapList( + lazyListState = rememberLazyListState(), + listItems = listState, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = false, + ) + }, + appBarContent = { + KeyMapAppBar(state = appBarState) + }, + selectionBottomSheet = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewKeyMapsPaused() { + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = true, + ) + + val listState = State.Data(sampleList()) + + KeyMapperTheme { + HomeKeyMapListScreen( + floatingActionButton = { + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) + }, + listContent = { + KeyMapList( + lazyListState = rememberLazyListState(), + listItems = listState, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = false, + ) + }, + appBarContent = { + KeyMapAppBar(state = appBarState) + }, + selectionBottomSheet = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewKeyMapsWarnings() { + val ctx = LocalContext.current + + val warnings = listOf( + HomeWarningListItem( + id = "0", + text = stringResource(R.string.home_error_accessibility_service_is_disabled), + ), + HomeWarningListItem( + id = "1", + text = stringResource(R.string.home_error_is_battery_optimised), + ), + ) + + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = listOf( + GroupListItemModel( + uid = "0", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ), + warnings = warnings, + isPaused = true, + ) + + val listState = State.Data(sampleList()) + + KeyMapperTheme { + HomeKeyMapListScreen( + floatingActionButton = { + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) + }, + listContent = { + KeyMapList( + lazyListState = rememberLazyListState(), + listItems = listState, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = false, + ) + }, + appBarContent = { + KeyMapAppBar(state = appBarState) + }, + selectionBottomSheet = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewKeyMapsWarningsEmpty() { + val warnings = listOf( + HomeWarningListItem( + id = "0", + text = stringResource(R.string.home_error_accessibility_service_is_disabled), + ), + HomeWarningListItem( + id = "1", + text = stringResource(R.string.home_error_is_battery_optimised), + ), + ) + + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = warnings, + isPaused = true, + ) + + val listState = State.Data(emptyList()) + + KeyMapperTheme { + HomeKeyMapListScreen( + floatingActionButton = { + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) + }, + listContent = { + KeyMapList( + lazyListState = rememberLazyListState(), + listItems = listState, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = false, + ) + }, + appBarContent = { + KeyMapAppBar(state = appBarState) + }, + selectionBottomSheet = {}, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeScreen.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeScreen.kt index 2657457d82..d1b08873db 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeScreen.kt @@ -1,119 +1,36 @@ package io.github.sds100.keymapper.home -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.HelpOutline -import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.outlined.BubbleChart -import androidx.compose.material.icons.outlined.Gamepad -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.DeleteOutline -import androidx.compose.material.icons.rounded.ErrorOutline -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.IosShare -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.PauseCircleOutline -import androidx.compose.material.icons.rounded.PlayCircleOutline -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf 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.graphics.lerp -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -122,259 +39,53 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.backup.ImportExportState -import io.github.sds100.keymapper.backup.RestoreType -import io.github.sds100.keymapper.compose.KeyMapperTheme -import io.github.sds100.keymapper.compose.LocalCustomColorsPalette -import io.github.sds100.keymapper.floating.FloatingLayoutsScreen -import io.github.sds100.keymapper.mappings.keymaps.KeyMapListScreen -import io.github.sds100.keymapper.mappings.keymaps.trigger.DpadTriggerSetupBottomSheet -import io.github.sds100.keymapper.sorting.SortBottomSheet -import io.github.sds100.keymapper.system.files.FileUtils -import io.github.sds100.keymapper.util.ShareUtils -import io.github.sds100.keymapper.util.ui.NavDestination -import io.github.sds100.keymapper.util.ui.NavigateEvent -import io.github.sds100.keymapper.util.ui.compose.icons.Import -import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons -import kotlinx.coroutines.launch +import io.github.sds100.keymapper.util.ui.SelectionState -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( + modifier: Modifier = Modifier, viewModel: HomeViewModel, onSettingsClick: () -> Unit, onAboutClick: () -> Unit, finishActivity: () -> Unit, startDestination: HomeDestination = HomeDestination.KeyMaps, ) { - val homeState by viewModel.state.collectAsStateWithLifecycle() - val navController = rememberNavController() val navBarItems by viewModel.navBarItems.collectAsStateWithLifecycle() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val setupGuiKeyboardState by viewModel.keyMapListViewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() - - if (viewModel.keyMapListViewModel.showDpadTriggerSetupBottomSheet) { - DpadTriggerSetupBottomSheet( - modifier = Modifier.systemBarsPadding(), - onDismissRequest = { - viewModel.keyMapListViewModel.showDpadTriggerSetupBottomSheet = - false - }, - guiKeyboardState = setupGuiKeyboardState, - onEnableKeyboardClick = viewModel.keyMapListViewModel::onEnableGuiKeyboardClick, - onChooseKeyboardClick = viewModel.keyMapListViewModel::onChooseGuiKeyboardClick, - onNeverShowAgainClick = viewModel.keyMapListViewModel::onNeverShowSetupDpadClick, - sheetState = sheetState, - ) - } - - if (viewModel.showSortBottomSheet) { - SortBottomSheet( - viewModel = viewModel.sortViewModel, - onDismissRequest = { viewModel.showSortBottomSheet = false }, - sheetState = sheetState, - ) - } - - val importFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri ?: return@rememberLauncherForActivityResult - - viewModel.onChooseImportFile(uri.toString()) - } - - val scope = rememberCoroutineScope() - val uriHandler = LocalUriHandler.current - val helpUrl = stringResource(R.string.url_quick_start_guide) - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val snackbarState = remember { SnackbarHostState() } - - val importExportState by viewModel.importExportState.collectAsStateWithLifecycle() - importExportState.also { exportState -> - when (exportState) { - is ImportExportState.Error -> { - val text = stringResource(R.string.home_export_error_snackbar, exportState.error) - scope.launch { - snackbarState.currentSnackbarData?.dismiss() - snackbarState.showSnackbar(text, duration = SnackbarDuration.Short) - viewModel.setImportExportIdle() - } - } - - ImportExportState.Exporting -> { - val text = stringResource(R.string.home_exporting_snackbar) - scope.launch { - snackbarState.showSnackbar(text, duration = SnackbarDuration.Indefinite) - } - } - - ImportExportState.Importing -> { - val text = stringResource(R.string.home_importing_snackbar) - scope.launch { - snackbarState.showSnackbar(text, duration = SnackbarDuration.Indefinite) - } - } - - is ImportExportState.FinishedExport -> { - snackbarState.currentSnackbarData?.dismiss() - LocalActivity.current?.let { ShareUtils.shareFile(it, exportState.uri.toUri()) } - viewModel.setImportExportIdle() - } - - is ImportExportState.FinishedImport -> { - val text = stringResource(R.string.home_importing_finished_snackbar) - scope.launch { - snackbarState.currentSnackbarData?.dismiss() - snackbarState.showSnackbar(text, duration = SnackbarDuration.Short) - viewModel.setImportExportIdle() - } - } - - ImportExportState.Idle -> { - snackbarState.currentSnackbarData?.dismiss() - } - - is ImportExportState.ConfirmImport -> { - snackbarState.currentSnackbarData?.dismiss() - ImportDialog( - keyMapCount = exportState.keyMapCount, - onDismissRequest = viewModel::setImportExportIdle, - onAppendClick = { viewModel.onConfirmImport(RestoreType.APPEND) }, - onReplaceClick = { viewModel.onConfirmImport(RestoreType.REPLACE) }, - ) - } - } - } - - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } - - if (showDeleteDialog) { - DeleteKeyMapsDialog( - keyMapCount = (homeState as? HomeState.Selecting)?.selectionCount ?: 0, - onDismissRequest = { showDeleteDialog = false }, - onDeleteClick = { - viewModel.onDeleteSelectedKeyMapsClick() - showDeleteDialog = false - }, - ) - } - - val keyMapLazyListState = rememberLazyListState() - val floatingLayoutsLazyListState = rememberLazyListState() + val selectionState by viewModel.keyMapListViewModel.multiSelectProvider.state.collectAsStateWithLifecycle() HomeScreen( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - navController = navController, + modifier = modifier, + isSelectingKeyMaps = selectionState is SelectionState.Selecting, startDestination = startDestination, - homeState = homeState, - snackBarState = snackbarState, + navController = navController, navBarItems = navBarItems, - topAppBar = { - HomeAppBar( - scrollBehavior = scrollBehavior, - homeState = homeState, + keyMapsContent = { + HomeKeyMapListScreen( + viewModel = viewModel.keyMapListViewModel, + snackbarState = snackbarState, onSettingsClick = onSettingsClick, onAboutClick = onAboutClick, - onSortClick = { viewModel.showSortBottomSheet = true }, - onHelpClick = { uriHandler.openUri(helpUrl) }, - onExportClick = viewModel::onExportClick, - onImportClick = { importFileLauncher.launch(FileUtils.MIME_TYPE_ALL) }, - onTogglePausedClick = viewModel::onTogglePausedClick, - onFixWarningClick = viewModel::onFixWarningClick, - onBackClick = { - if (!viewModel.onBackClick()) { - finishActivity() - } + finishActivity = finishActivity, + fabBottomPadding = if (navBarItems.size == 1) { + 0.dp + } else { + 80.dp }, - onSelectAllClick = viewModel::onSelectAllClick, - ) - }, - keyMapsContent = { - KeyMapListScreen( - modifier = Modifier.fillMaxSize(), - viewModel = viewModel.keyMapListViewModel, - lazyListState = keyMapLazyListState, ) }, floatingButtonsContent = { - FloatingLayoutsScreen( - Modifier.fillMaxSize(), + HomeFloatingLayoutsScreen( viewModel = viewModel.listFloatingLayoutsViewModel, navController = navController, - lazyListState = floatingLayoutsLazyListState, - ) - }, - floatingActionButton = { - val isFloatingLayoutsDestination = - currentDestination?.route == HomeDestination.FloatingButtons.route - - val showFab = if (homeState is HomeState.Normal) { - if (isFloatingLayoutsDestination) { - (homeState as HomeState.Normal).showNewLayoutButton + snackbarState = snackbarState, + fabBottomPadding = if (navBarItems.size == 1) { + 0.dp } else { - true - } - } else { - false - } - - if (showFab) { - FloatingActionButton( - onClick = { - if (isFloatingLayoutsDestination) { - viewModel.listFloatingLayoutsViewModel.onNewLayoutClick() - } else { - scope.launch { - viewModel.navigate( - NavigateEvent( - "config_key_map", - NavDestination.ConfigKeyMap(keyMapUid = null), - ), - ) - } - } - }, - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val fabText = when (currentDestination?.route) { - HomeDestination.FloatingButtons.route -> stringResource(R.string.home_fab_new_floating_layout) - else -> stringResource(R.string.home_fab_new_key_map) - } - - Icon(Icons.Rounded.Add, contentDescription = fabText) - - val isFabTextVisible = if (isFloatingLayoutsDestination) { - viewModel.listFloatingLayoutsViewModel.showFabText - } else { - viewModel.keyMapListViewModel.showFabText - } - - AnimatedVisibility(isFabTextVisible) { - AnimatedContent(fabText) { text -> - Text(modifier = Modifier.padding(start = 8.dp), text = text) - } - } - } - } - } - }, - selectionBottomSheet = { state -> - SelectionBottomSheet( - enabled = state.selectionCount > 0, - selectedKeyMapsEnabled = state.selectedKeyMapsEnabled, - onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, - onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, - onExportClick = viewModel::onExportSelectedKeyMaps, - onDeleteClick = { showDeleteDialog = true }, + 80.dp + }, ) }, ) @@ -383,37 +94,42 @@ fun HomeScreen( @Composable private fun HomeScreen( modifier: Modifier = Modifier, - homeState: HomeState, + isSelectingKeyMaps: Boolean, startDestination: HomeDestination = HomeDestination.KeyMaps, navController: NavHostController, - snackBarState: SnackbarHostState = SnackbarHostState(), navBarItems: List, - topAppBar: @Composable () -> Unit, keyMapsContent: @Composable () -> Unit, floatingButtonsContent: @Composable () -> Unit, - floatingActionButton: @Composable () -> Unit = {}, - selectionBottomSheet: @Composable (state: HomeState.Selecting) -> Unit = {}, ) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination - Scaffold( - modifier = modifier - // Only take the horizontal because the status bar is the same color as the app bar + Column( + modifier // Only take the horizontal because the status bar is the same color as the app bar .windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) .navigationBarsPadding(), - topBar = topAppBar, - snackbarHost = { - SnackbarHost(hostState = snackBarState) - }, - floatingActionButton = floatingActionButton, - bottomBar = { - if (navBarItems.size <= 1) { - return@Scaffold + ) { + Box(contentAlignment = Alignment.BottomCenter) { + NavHost( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + navController = navController, + startDestination = startDestination.route, + // use no animations because otherwise the transition freezes + // when quickly navigating to another page while the transition is still happening. + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { + composable(HomeDestination.KeyMaps.route) { + keyMapsContent() + } + composable(HomeDestination.FloatingButtons.route) { + floatingButtonsContent() + } } - AnimatedVisibility( - homeState is HomeState.Normal, + this@Column.AnimatedVisibility( + visible = !isSelectingKeyMaps && navBarItems.size > 1, enter = slideInVertically { it }, exit = slideOutVertically { it }, ) { @@ -478,805 +194,6 @@ private fun HomeScreen( } } } - }, - ) { innerPadding -> - val layoutDirection = LocalLayoutDirection.current - val startPadding = innerPadding.calculateStartPadding(layoutDirection) - val endPadding = innerPadding.calculateEndPadding(layoutDirection) - - Box(contentAlignment = Alignment.BottomCenter) { - NavHost( - modifier = Modifier - .fillMaxSize() - .padding( - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding(), - start = startPadding, - end = endPadding, - ), - contentAlignment = Alignment.TopCenter, - navController = navController, - startDestination = startDestination.route, - // use no animations because otherwise the transition freezes - // when quickly navigating to another page while the transition is still happening. - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, - ) { - composable(HomeDestination.KeyMaps.route) { - keyMapsContent() - } - composable(HomeDestination.FloatingButtons.route) { - floatingButtonsContent() - } - } - - AnimatedVisibility( - visible = homeState is HomeState.Selecting, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - ) { - if (homeState is HomeState.Selecting) { - selectionBottomSheet(homeState) - } - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun HomeAppBar( - homeState: HomeState, - onSettingsClick: () -> Unit = {}, - onAboutClick: () -> Unit = {}, - onSortClick: () -> Unit = {}, - onHelpClick: () -> Unit = {}, - onTogglePausedClick: () -> Unit = {}, - onFixWarningClick: (String) -> Unit = {}, - onExportClick: () -> Unit = {}, - onImportClick: () -> Unit = {}, - onBackClick: () -> Unit = {}, - onSelectAllClick: () -> Unit = {}, - scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), -) { - // This is taken from the AppBar color code. - val colorTransitionFraction by - remember(scrollBehavior) { - // derivedStateOf to prevent redundant recompositions when the content scrolls. - derivedStateOf { - val overlappingFraction = scrollBehavior.state.overlappedFraction - if (overlappingFraction > 0.01f) 1f else 0f - } - } - val appBarColors = if (homeState is HomeState.Selecting) { - TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } else { - TopAppBarDefaults.centerAlignedTopAppBarColors() - } - - val appBarContainerColor by animateColorAsState( - targetValue = lerp( - appBarColors.containerColor, - appBarColors.scrolledContainerColor, - FastOutLinearInEasing.transform(colorTransitionFraction), - ), - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ) - - var expandedDropdown by rememberSaveable { mutableStateOf(false) } - - BackHandler(onBack = onBackClick) - - Column { - CenterAlignedTopAppBar( - scrollBehavior = scrollBehavior, - title = { - when (homeState) { - is HomeState.Normal -> AppBarStatus( - homeState = homeState, - onTogglePausedClick = onTogglePausedClick, - ) - - is HomeState.Selecting -> SelectedText(selectionCount = homeState.selectionCount) - } - }, - navigationIcon = { - AnimatedContent(homeState is HomeState.Selecting) { isSelecting -> - if (isSelecting) { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.home_app_bar_cancel_selecting), - ) - } - } else { - IconButton(onClick = onSortClick) { - Icon( - Icons.AutoMirrored.Rounded.Sort, - contentDescription = stringResource(R.string.home_app_bar_sort), - ) - } - } - } - }, - actions = { - AnimatedContent(homeState is HomeState.Selecting) { isSelecting -> - if (isSelecting && homeState is HomeState.Selecting) { - OutlinedButton( - modifier = Modifier.padding(horizontal = 8.dp), - onClick = onSelectAllClick, - ) { - val text = if (homeState.isAllSelected) { - stringResource(R.string.home_app_bar_deselect_all) - } else { - stringResource(R.string.home_app_bar_select_all) - } - Text(text) - } - } else { - Row { - IconButton(onClick = onHelpClick) { - Icon( - Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = stringResource(R.string.home_app_bar_help), - ) - } - - IconButton(onClick = { expandedDropdown = true }) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = stringResource(R.string.home_app_bar_more), - ) - } - - HomeDropdownMenu( - expanded = expandedDropdown, - onSettingsClick = { - expandedDropdown = false - onSettingsClick() - }, - onAboutClick = { - expandedDropdown = false - onAboutClick() - }, - onExportClick = { - expandedDropdown = false - onExportClick() - }, - onImportClick = { - expandedDropdown = false - onImportClick() - }, - onDismissRequest = { expandedDropdown = false }, - ) - } - } - } - }, - colors = appBarColors, - ) - AnimatedVisibility(homeState is HomeState.Normal && homeState.warnings.isNotEmpty()) { - Surface(color = appBarContainerColor) { - WarningList( - modifier = Modifier.padding(bottom = 8.dp), - warnings = (homeState as? HomeState.Normal)?.warnings ?: emptyList(), - onFixClick = onFixWarningClick, - ) - } - } - } -} - -@Composable -private fun SelectedText(modifier: Modifier = Modifier, selectionCount: Int) { - Row(modifier) { - AnimatedContent( - selectionCount, - transitionSpec = { - selectedTextTransition( - targetState, - initialState, - ) - }, - ) { selectionCount -> - Text(selectionCount.toString()) - } - - Spacer(Modifier.width(4.dp)) - - Text(stringResource(R.string.selection_count)) - } -} - -private fun selectedTextTransition( - targetState: Int, - initialState: Int, -): ContentTransform { - return slideInVertically { height -> - if (targetState > initialState) { - -height - } else { - height } - } + fadeIn() togetherWith slideOutVertically { height -> - if (targetState > initialState) { - height - } else { - -height - } - } + fadeOut() -} - -@Composable -private fun HomeDropdownMenu( - expanded: Boolean, - onSettingsClick: () -> Unit = {}, - onAboutClick: () -> Unit = {}, - onExportClick: () -> Unit = {}, - onImportClick: () -> Unit = {}, - onDismissRequest: () -> Unit = {}, -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - ) { - DropdownMenuItem( - leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) }, - text = { Text(stringResource(R.string.home_menu_settings)) }, - onClick = onSettingsClick, - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Rounded.IosShare, contentDescription = null) }, - text = { Text(stringResource(R.string.home_menu_export)) }, - onClick = onExportClick, - ) - DropdownMenuItem( - leadingIcon = { Icon(KeyMapperIcons.Import, contentDescription = null) }, - text = { Text(stringResource(R.string.home_menu_import)) }, - onClick = onImportClick, - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Rounded.Info, contentDescription = null) }, - text = { Text(stringResource(R.string.home_menu_about)) }, - onClick = onAboutClick, - ) - } -} - -@Composable -private fun AppBarStatus( - homeState: HomeState.Normal, - onTogglePausedClick: () -> Unit, -) { - val pausedButtonContainerColor by animateColorAsState( - targetValue = if (homeState.isPaused || homeState.warnings.isNotEmpty()) { - MaterialTheme.colorScheme.errorContainer - } else { - LocalCustomColorsPalette.current.greenContainer - }, - ) - - val pausedButtonContentColor by animateColorAsState( - targetValue = if (homeState.isPaused || homeState.warnings.isNotEmpty()) { - MaterialTheme.colorScheme.onErrorContainer - } else { - LocalCustomColorsPalette.current.onGreenContainer - }, - ) - - FilledTonalButton( - modifier = Modifier.widthIn(min = 8.dp), - onClick = onTogglePausedClick, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = pausedButtonContainerColor, - contentColor = pausedButtonContentColor, - ), - contentPadding = PaddingValues(horizontal = 12.dp), - ) { - val buttonIcon: ImageVector - val buttonText: String - - if (homeState.isPaused) { - buttonIcon = Icons.Rounded.PauseCircleOutline - buttonText = stringResource(R.string.home_app_bar_status_paused) - } else if (homeState.warnings.isNotEmpty()) { - buttonIcon = Icons.Rounded.ErrorOutline - buttonText = pluralStringResource( - R.plurals.home_app_bar_status_warnings, - homeState.warnings.size, - homeState.warnings.size, - ) - } else { - buttonIcon = Icons.Rounded.PlayCircleOutline - buttonText = stringResource(R.string.home_app_bar_status_running) - } - - val transition = - slideInVertically { height -> -height } + fadeIn() togetherWith slideOutVertically { height -> height } + fadeOut() - - AnimatedContent(targetState = buttonIcon, transitionSpec = { transition }) { icon -> - Icon(icon, contentDescription = null) - } - - AnimatedContent( - targetState = buttonText, - transitionSpec = { transition }, - ) { text -> - Row { - Spacer(modifier = Modifier.width(4.dp)) - Text(text) - } - } - } -} - -@Composable -private fun WarningList( - modifier: Modifier = Modifier, - warnings: List, - onFixClick: (String) -> Unit, -) { - OutlinedCard( - modifier = modifier.padding(horizontal = 8.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), - elevation = CardDefaults.outlinedCardElevation(defaultElevation = 5.dp), - ) { - Column( - Modifier.padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (warning in warnings) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Rounded.ErrorOutline, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - modifier = Modifier.weight(1f), - text = warning.text, - style = MaterialTheme.typography.bodyMedium, - ) - - Spacer(modifier = Modifier.width(8.dp)) - - FilledTonalButton( - onClick = { onFixClick(warning.id) }, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(stringResource(R.string.button_fix)) - } - } - } - } - } -} - -@Composable -private fun ImportDialog( - modifier: Modifier = Modifier, - keyMapCount: Int, - onDismissRequest: () -> Unit, - onAppendClick: () -> Unit, - onReplaceClick: () -> Unit, -) { - AlertDialog( - modifier = modifier, - onDismissRequest = onDismissRequest, - title = { - Text( - pluralStringResource( - R.plurals.home_importing_dialog_title, - keyMapCount, - keyMapCount, - ), - ) - }, - text = { - Text( - stringResource(R.string.home_importing_dialog_text, keyMapCount), - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - TextButton(onClick = onAppendClick) { - Text(stringResource(R.string.home_importing_dialog_append)) - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(stringResource(R.string.home_importing_dialog_cancel)) - } - - TextButton(onClick = onReplaceClick) { - Text(stringResource(R.string.home_importing_dialog_replace)) - } - }, - ) -} - -@Composable -private fun DeleteKeyMapsDialog( - modifier: Modifier = Modifier, - keyMapCount: Int, - onDismissRequest: () -> Unit, - onDeleteClick: () -> Unit, -) { - AlertDialog( - modifier = modifier, - onDismissRequest = onDismissRequest, - title = { - Text( - pluralStringResource( - R.plurals.home_key_maps_delete_dialog_title, - keyMapCount, - keyMapCount, - ), - ) - }, - text = { - Text( - stringResource(R.string.home_key_maps_delete_dialog_text, keyMapCount), - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - TextButton(onClick = onDeleteClick) { - Text(stringResource(R.string.home_key_maps_delete_yes)) - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text(stringResource(R.string.home_key_maps_delete_cancel)) - } - }, - ) -} - -@Composable -private fun SelectionBottomSheet( - modifier: Modifier = Modifier, - enabled: Boolean, - selectedKeyMapsEnabled: SelectedKeyMapsEnabled, - onDuplicateClick: () -> Unit = {}, - onDeleteClick: () -> Unit = {}, - onExportClick: () -> Unit = {}, - onEnabledKeyMapsChange: (Boolean) -> Unit = {}, -) { - @OptIn(ExperimentalMaterial3Api::class) - Surface( - modifier = modifier - .widthIn(max = BottomSheetDefaults.SheetMaxWidth) - .fillMaxWidth() - .navigationBarsPadding(), - shadowElevation = 5.dp, - shape = BottomSheetDefaults.ExpandedShape, - tonalElevation = BottomSheetDefaults.Elevation, - color = BottomSheetDefaults.ContainerColor, - ) { - Row( - modifier = Modifier - .padding(16.dp) - .height(intrinsicSize = IntrinsicSize.Min), - ) { - Row( - modifier = Modifier - .weight(1f) - .horizontalScroll(state = rememberScrollState()), - ) { - SelectionButton( - text = stringResource(R.string.home_multi_select_duplicate), - icon = Icons.Rounded.ContentCopy, - enabled = enabled, - onClick = onDuplicateClick, - ) - - SelectionButton( - text = stringResource(R.string.home_multi_select_delete), - icon = Icons.Rounded.DeleteOutline, - enabled = enabled, - onClick = onDeleteClick, - ) - - SelectionButton( - text = stringResource(R.string.home_multi_select_export), - icon = Icons.Rounded.IosShare, - enabled = enabled, - onClick = onExportClick, - ) - } - - VerticalDivider(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)) - - KeyMapsEnabledSwitch( - modifier = Modifier.width(IntrinsicSize.Max), - state = selectedKeyMapsEnabled, - enabled = enabled, - onCheckedChange = onEnabledKeyMapsChange, - ) - } - } -} - -@Composable -private fun SelectionButton( - modifier: Modifier = Modifier, - text: String, - icon: ImageVector, - enabled: Boolean, - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - Column( - modifier - .padding(4.dp) - .width(72.dp) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - enabled = enabled, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - IconButton(onClick = onClick, interactionSource = interactionSource, enabled = enabled) { - Icon(icon, text) - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = if (enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun KeyMapsEnabledSwitch( - modifier: Modifier = Modifier, - state: SelectedKeyMapsEnabled, - enabled: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Column( - modifier.padding(4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Switch( - checked = state == SelectedKeyMapsEnabled.ALL, - onCheckedChange = onCheckedChange, - enabled = enabled, - ) - val text = when (state) { - SelectedKeyMapsEnabled.ALL -> stringResource(R.string.home_enabled_key_maps_enabled) - SelectedKeyMapsEnabled.NONE -> stringResource(R.string.home_enabled_key_maps_disabled) - SelectedKeyMapsEnabled.MIXED -> stringResource(R.string.home_enabled_key_maps_mixed) - } - - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - color = if (enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } -} - -private fun sampleNavBarItems(): List { - return listOf( - HomeNavBarItem( - icon = Icons.Outlined.Gamepad, - label = "Key Maps", - destination = HomeDestination.KeyMaps, - ), - HomeNavBarItem( - icon = Icons.Outlined.BubbleChart, - label = "Floating Buttons", - destination = HomeDestination.FloatingButtons, - badge = "NEW!", - ), - ) -} - -@Preview -@Composable -private fun ImportDialogPreview() { - KeyMapperTheme { - ImportDialog( - keyMapCount = 3, - onDismissRequest = {}, - onAppendClick = {}, - onReplaceClick = {}, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun HomeStateRunningPreview() { - val state = HomeState.Normal(warnings = emptyList(), isPaused = false) - KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun HomeStatePausedPreview() { - val state = HomeState.Normal(warnings = emptyList(), isPaused = true) - KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun HomeStateWarningsPreview() { - val state = HomeState.Normal( - warnings = listOf( - HomeWarningListItem( - id = "0", - text = stringResource(R.string.home_error_accessibility_service_is_disabled), - ), - HomeWarningListItem( - id = "1", - text = stringResource(R.string.home_error_is_battery_optimised), - ), - ), - isPaused = false, - ) - KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun HomeStateWarningsDarkPreview() { - val state = HomeState.Normal( - warnings = listOf( - HomeWarningListItem( - id = "0", - text = stringResource(R.string.home_error_accessibility_service_is_disabled), - ), - HomeWarningListItem( - id = "1", - text = stringResource(R.string.home_error_is_battery_optimised), - ), - ), - isPaused = false, - ) - KeyMapperTheme(darkTheme = true) { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(widthDp = 300, heightDp = 600) -@Composable -private fun HomeStateSelectingPreview() { - val state = HomeState.Selecting( - selectionCount = 4, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, - isAllSelected = false, - ) - KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - selectionBottomSheet = { - SelectionBottomSheet( - enabled = true, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL, - ) - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) -@Composable -private fun HomeStateSelectingDisabledPreview() { - val state = HomeState.Selecting( - selectionCount = 4, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, - isAllSelected = true, - ) - KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - selectionBottomSheet = { - SelectionBottomSheet( - enabled = false, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, - ) - }, - ) - } -} - -@Preview -@Composable -private fun DropdownPreview() { - KeyMapperTheme { - HomeDropdownMenu( - expanded = true, - ) - } -} - -@Preview -@Composable -private fun DropdownExportingPreview() { - KeyMapperTheme { - HomeDropdownMenu( - expanded = true, - ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt index 2262832d9c..9b5212b89a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -3,10 +3,7 @@ package io.github.sds100.keymapper.home import android.os.Build import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BubbleChart -import androidx.compose.material.icons.outlined.Gamepad -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -14,48 +11,25 @@ import androidx.lifecycle.viewModelScope import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.R import io.github.sds100.keymapper.backup.BackupRestoreMappingsUseCase -import io.github.sds100.keymapper.backup.ImportExportState -import io.github.sds100.keymapper.backup.RestoreType -import io.github.sds100.keymapper.floating.FloatingLayoutsState import io.github.sds100.keymapper.floating.ListFloatingLayoutsUseCase import io.github.sds100.keymapper.floating.ListFloatingLayoutsViewModel -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase -import io.github.sds100.keymapper.mappings.keymaps.KeyMap +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.KeyMapListViewModel import io.github.sds100.keymapper.mappings.keymaps.ListKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.trigger.SetupGuiKeyboardUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase import io.github.sds100.keymapper.sorting.SortKeyMapsUseCase -import io.github.sds100.keymapper.sorting.SortViewModel -import io.github.sds100.keymapper.system.accessibility.ServiceState -import io.github.sds100.keymapper.util.Error -import io.github.sds100.keymapper.util.Result -import io.github.sds100.keymapper.util.State -import io.github.sds100.keymapper.util.Success -import io.github.sds100.keymapper.util.getFullMessage -import io.github.sds100.keymapper.util.onFailure -import io.github.sds100.keymapper.util.onSuccess import io.github.sds100.keymapper.util.ui.DialogResponse -import io.github.sds100.keymapper.util.ui.MultiSelectProvider -import io.github.sds100.keymapper.util.ui.NavDestination import io.github.sds100.keymapper.util.ui.NavigationViewModel import io.github.sds100.keymapper.util.ui.NavigationViewModelImpl import io.github.sds100.keymapper.util.ui.PopupUi import io.github.sds100.keymapper.util.ui.PopupViewModel import io.github.sds100.keymapper.util.ui.PopupViewModelImpl import io.github.sds100.keymapper.util.ui.ResourceProvider -import io.github.sds100.keymapper.util.ui.SelectionState -import io.github.sds100.keymapper.util.ui.ViewModelHelper -import io.github.sds100.keymapper.util.ui.navigate import io.github.sds100.keymapper.util.ui.showPopup -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.stateIn @@ -66,7 +40,7 @@ import kotlinx.coroutines.launch */ class HomeViewModel( private val listKeyMaps: ListKeyMapsUseCase, - private val pauseMappings: PauseMappingsUseCase, + private val pauseKeyMaps: PauseKeyMapsUseCase, private val backupRestore: BackupRestoreMappingsUseCase, private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, private val onboarding: OnboardingUseCase, @@ -79,14 +53,6 @@ class HomeViewModel( PopupViewModel by PopupViewModelImpl(), NavigationViewModel by NavigationViewModelImpl() { - private companion object { - const val ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM = "accessibility_service_disabled" - const val ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM = "accessibility_service_crashed" - const val ID_BATTERY_OPTIMISATION_LIST_ITEM = "battery_optimised" - const val ID_LOGGING_ENABLED_LIST_ITEM = "logging_enabled" - } - - private val multiSelectProvider: MultiSelectProvider = MultiSelectProvider() val navBarItems: StateFlow> = combine( listFloatingLayouts.showFloatingLayouts, @@ -107,9 +73,11 @@ class HomeViewModel( viewModelScope, listKeyMaps, resourceProvider, - multiSelectProvider, setupGuiKeyboard, sortKeyMaps, + showAlertsUseCase, + pauseKeyMaps, + backupRestore, ) } @@ -121,124 +89,12 @@ class HomeViewModel( ) } - val sortViewModel by lazy { - SortViewModel(viewModelScope, sortKeyMaps) - } - - var showSortBottomSheet by mutableStateOf(false) - - private val _importExportState = MutableStateFlow(ImportExportState.Idle) - val importExportState: StateFlow = _importExportState.asStateFlow() - - private val warnings: Flow> = combine( - showAlertsUseCase.isBatteryOptimised, - showAlertsUseCase.accessibilityServiceState, - showAlertsUseCase.hideAlerts, - showAlertsUseCase.isLoggingEnabled, - ) { isBatteryOptimised, serviceState, isHidden, isLoggingEnabled -> - if (isHidden) { - return@combine emptyList() - } - - buildList { - when (serviceState) { - ServiceState.CRASHED -> - add( - HomeWarningListItem( - ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM, - getString(R.string.home_error_accessibility_service_is_crashed), - ), - ) - - ServiceState.DISABLED -> - add( - HomeWarningListItem( - ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM, - getString(R.string.home_error_accessibility_service_is_disabled), - ), - ) - - ServiceState.ENABLED -> {} - } - - if (isBatteryOptimised) { - add( - HomeWarningListItem( - ID_BATTERY_OPTIMISATION_LIST_ITEM, - getString(R.string.home_error_is_battery_optimised), - ), - ) - } // don't show a success message for this - - if (isLoggingEnabled) { - add( - HomeWarningListItem( - ID_LOGGING_ENABLED_LIST_ITEM, - getString(R.string.home_error_logging_enabled), - ), - ) - } - } - } - - val state: StateFlow = - combine( - multiSelectProvider.state, - warnings, - showAlertsUseCase.areKeyMapsPaused, - listKeyMaps.keyMapList.filterIsInstance>>(), - listFloatingLayoutsViewModel.state, - ) { selectionState, warnings, isPaused, keyMaps, floatingLayoutsState -> - - if (selectionState is SelectionState.Selecting) { - - var selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = null - - for (keyMap in keyMaps.data) { - if (keyMap.uid in selectionState.selectedIds) { - if (selectedKeyMapsEnabled == null) { - if (keyMap.isEnabled) { - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL - } else { - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE - } - } else { - if ((keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.NONE) || - (!keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.ALL) - ) { - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED - break - } - } - } - } - - HomeState.Selecting( - selectionCount = multiSelectProvider.getSelectedIds().size, - selectedKeyMapsEnabled = selectedKeyMapsEnabled ?: SelectedKeyMapsEnabled.NONE, - isAllSelected = selectionState.selectedIds.size == keyMaps.data.size, - ) - } else { - HomeState.Normal( - warnings, - isPaused, - showNewLayoutButton = floatingLayoutsState is FloatingLayoutsState.Purchased, - ) - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, HomeState.Normal()) - init { - viewModelScope.launch { - backupRestore.onAutomaticBackupResult.collectLatest { result -> - onAutomaticBackupResult(result) - } - } combine( onboarding.showWhatsNew, onboarding.showQuickStartGuideHint, ) { showWhatsNew, showQuickStartGuideHint -> - if (showWhatsNew) { showWhatsNewDialog() } @@ -260,7 +116,7 @@ class HomeViewModel( HomeNavBarItem( HomeDestination.KeyMaps, getString(R.string.home_nav_bar_key_maps), - icon = Icons.Outlined.Gamepad, + icon = Icons.Outlined.Keyboard, badge = null, ), ) @@ -301,28 +157,6 @@ class HomeViewModel( onboarding.showedWhatsNew() } - private suspend fun onAutomaticBackupResult(result: Result<*>) { - when (result) { - is Success -> {} - - is Error -> { - val response = showPopup( - "automatic_backup_error", - PopupUi.Dialog( - title = getString(R.string.toast_automatic_backup_failed), - message = result.getFullMessage(this), - positiveButtonText = getString(R.string.pos_ok), - neutralButtonText = getString(R.string.neutral_go_to_settings), - ), - ) ?: return - - if (response == DialogResponse.NEUTRAL) { - navigate("settings", NavDestination.Settings) - } - } - } - } - private suspend fun showUpgradeGuiKeyboardDialog() { val dialog = PopupUi.Dialog( title = getString(R.string.dialog_upgrade_gui_keyboard_title), @@ -341,174 +175,10 @@ class HomeViewModel( } } - fun onSelectAllClick() { - state.value.also { state -> - if (state is HomeState.Selecting) { - if (state.isAllSelected) { - multiSelectProvider.stopSelecting() - } else { - keyMapListViewModel.selectAll() - } - } - } - } - - fun onEnabledKeyMapsChange(enabled: Boolean) { - val selectionState = multiSelectProvider.state.value - - if (selectionState !is SelectionState.Selecting) return - val selectedIds = selectionState.selectedIds - - if (enabled) { - listKeyMaps.enableKeyMap(*selectedIds.toTypedArray()) - } else { - listKeyMaps.disableKeyMap(*selectedIds.toTypedArray()) - } - } - - fun onDuplicateSelectedKeyMapsClick() { - val selectionState = multiSelectProvider.state.value - - if (selectionState !is SelectionState.Selecting) return - val selectedIds = selectionState.selectedIds - - listKeyMaps.duplicateKeyMap(*selectedIds.toTypedArray()) - } - - fun onDeleteSelectedKeyMapsClick() { - val selectionState = multiSelectProvider.state.value - - if (selectionState !is SelectionState.Selecting) return - val selectedIds = selectionState.selectedIds.toTypedArray() - - listKeyMaps.deleteKeyMap(*selectedIds) - multiSelectProvider.deselect(*selectedIds) - multiSelectProvider.stopSelecting() - } - - fun onExportSelectedKeyMaps() { - val selectionState = multiSelectProvider.state.value - - if (selectionState !is SelectionState.Selecting) return - - viewModelScope.launch { - val selectedIds = selectionState.selectedIds - - listKeyMaps.backupKeyMaps(*selectedIds.toTypedArray()).onSuccess { - _importExportState.value = ImportExportState.FinishedExport(it) - }.onFailure { - _importExportState.value = - ImportExportState.Error(it.getFullMessage(this@HomeViewModel)) - } - } - } - - fun onFixWarningClick(id: String) { - viewModelScope.launch { - when (id) { - ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM -> { - val explanationResponse = - ViewModelHelper.showAccessibilityServiceExplanationDialog( - resourceProvider = this@HomeViewModel, - popupViewModel = this@HomeViewModel, - ) - - if (explanationResponse != DialogResponse.POSITIVE) { - return@launch - } - - if (!showAlertsUseCase.startAccessibilityService()) { - ViewModelHelper.handleCantFindAccessibilitySettings( - resourceProvider = this@HomeViewModel, - popupViewModel = this@HomeViewModel, - ) - } - } - - ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM -> - ViewModelHelper.handleKeyMapperCrashedDialog( - resourceProvider = this@HomeViewModel, - popupViewModel = this@HomeViewModel, - restartService = showAlertsUseCase::restartAccessibilityService, - ignoreCrashed = showAlertsUseCase::acknowledgeCrashed, - ) - - ID_BATTERY_OPTIMISATION_LIST_ITEM -> showAlertsUseCase.disableBatteryOptimisation() - ID_LOGGING_ENABLED_LIST_ITEM -> showAlertsUseCase.disableLogging() - } - } - } - - fun onTogglePausedClick() { - viewModelScope.launch { - if (pauseMappings.isPaused.first()) { - pauseMappings.resume() - } else { - pauseMappings.pause() - } - } - } - - fun onExportClick() { - viewModelScope.launch { - if (_importExportState.value != ImportExportState.Idle) { - return@launch - } - - _importExportState.value = ImportExportState.Exporting - backupRestore.backupEverything().onSuccess { - _importExportState.value = ImportExportState.FinishedExport(it) - }.onFailure { - _importExportState.value = - ImportExportState.Error(it.getFullMessage(this@HomeViewModel)) - } - } - } - - fun onChooseImportFile(uri: String) { - viewModelScope.launch { - backupRestore.getKeyMapCountInBackup(uri).onSuccess { - _importExportState.value = ImportExportState.ConfirmImport(uri, it) - }.onFailure { - _importExportState.value = - ImportExportState.Error(it.getFullMessage(this@HomeViewModel)) - } - } - } - - fun onConfirmImport(restoreType: RestoreType) { - val state = _importExportState.value as? ImportExportState.ConfirmImport - state ?: return - - _importExportState.value = ImportExportState.Importing - - viewModelScope.launch { - backupRestore.restoreKeyMaps(state.fileUri, restoreType).onSuccess { - _importExportState.value = ImportExportState.FinishedImport - }.onFailure { - _importExportState.value = - ImportExportState.Error(it.getFullMessage(this@HomeViewModel)) - } - } - } - - fun setImportExportIdle() { - _importExportState.value = ImportExportState.Idle - } - - fun onBackClick(): Boolean { - if (multiSelectProvider.state.value is SelectionState.Selecting) { - multiSelectProvider.stopSelecting() - return true - } else { - return false - } - } - @Suppress("UNCHECKED_CAST") class Factory( private val listKeyMaps: ListKeyMapsUseCase, - private val pauseMappings: PauseMappingsUseCase, + private val pauseMappings: PauseKeyMapsUseCase, private val backupRestore: BackupRestoreMappingsUseCase, private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, private val onboarding: OnboardingUseCase, @@ -532,20 +202,6 @@ class HomeViewModel( } } -sealed class HomeState { - data class Selecting( - val selectionCount: Int, - val selectedKeyMapsEnabled: SelectedKeyMapsEnabled, - val isAllSelected: Boolean, - ) : HomeState() - - data class Normal( - val warnings: List = emptyList(), - val isPaused: Boolean = false, - val showNewLayoutButton: Boolean = false, - ) : HomeState() -} - enum class SelectedKeyMapsEnabled { ALL, NONE, diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeWarningList.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeWarningList.kt new file mode 100644 index 0000000000..28007d2deb --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeWarningList.kt @@ -0,0 +1,75 @@ +package io.github.sds100.keymapper.home + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R + +@Composable +fun HomeWarningList( + modifier: Modifier = Modifier, + warnings: List, + onFixClick: (String) -> Unit, +) { + OutlinedCard( + modifier = modifier.padding(horizontal = 8.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + elevation = CardDefaults.outlinedCardElevation(defaultElevation = 5.dp), + ) { + Column( + Modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (warning in warnings) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = warning.text, + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + FilledTonalButton( + onClick = { onFixClick(warning.id) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.button_fix)) + } + } + } + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt b/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt new file mode 100644 index 0000000000..0a0ba913b6 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt @@ -0,0 +1,69 @@ +package io.github.sds100.keymapper.home + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme + +@Composable +fun ImportDialog( + modifier: Modifier = Modifier, + keyMapCount: Int, + onDismissRequest: () -> Unit, + onAppendClick: () -> Unit, + onReplaceClick: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text( + pluralStringResource( + R.plurals.home_importing_dialog_title, + keyMapCount, + keyMapCount, + ), + ) + }, + text = { + Text( + stringResource(R.string.home_importing_dialog_text, keyMapCount), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onAppendClick) { + Text(stringResource(R.string.home_importing_dialog_append)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.home_importing_dialog_cancel)) + } + + TextButton(onClick = onReplaceClick) { + Text(stringResource(R.string.home_importing_dialog_replace)) + } + }, + ) +} + +@Preview +@Composable +private fun ImportDialogPreview() { + KeyMapperTheme { + ImportDialog( + keyMapCount = 3, + onDismissRequest = {}, + onAppendClick = {}, + onReplaceClick = {}, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt new file mode 100644 index 0000000000..d952ec3ae4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -0,0 +1,1127 @@ +package io.github.sds100.keymapper.home + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Done +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.IosShare +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.PauseCircleOutline +import androidx.compose.material.icons.rounded.PlayCircleOutline +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.Constants +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.groups.DeleteGroupDialog +import io.github.sds100.keymapper.groups.GroupBreadcrumbRow +import io.github.sds100.keymapper.groups.GroupConstraintRow +import io.github.sds100.keymapper.groups.GroupListItemModel +import io.github.sds100.keymapper.groups.GroupRow +import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState +import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.RadioButtonText +import io.github.sds100.keymapper.util.ui.compose.icons.Import +import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun KeyMapAppBar( + modifier: Modifier = Modifier, + state: KeyMapAppBarState, + onSettingsClick: () -> Unit = {}, + onAboutClick: () -> Unit = {}, + onSortClick: () -> Unit = {}, + onHelpClick: () -> Unit = {}, + onTogglePausedClick: () -> Unit = {}, + onFixWarningClick: (String) -> Unit = {}, + onExportClick: () -> Unit = {}, + onImportClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onSelectAllClick: () -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + onNewGroupClick: () -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, + onRenameGroupClick: suspend (String) -> Boolean = { true }, + onEditGroupNameClick: () -> Unit = {}, + onDeleteGroupClick: () -> Unit = {}, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onConstraintModeChanged: (ConstraintMode) -> Unit = {}, + onFixConstraintClick: (Error) -> Unit = {}, +) { + BackHandler(onBack = onBackClick) + + // Use the class as the content key so the content is animated if the data inside the + // same state class changes. + AnimatedContent(state, contentKey = { it::class }) { state -> + when (state) { + is KeyMapAppBarState.RootGroup -> RootGroupAppBar( + modifier = modifier, + state = state, + scrollBehavior = scrollBehavior, + onTogglePausedClick = onTogglePausedClick, + onSortClick = onSortClick, + onFixWarningClick = onFixWarningClick, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + actions = { + AppBarActions( + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + ) + }, + ) + + is KeyMapAppBarState.Selecting -> SelectingAppBar( + modifier = modifier, + state = state, + onBackClick = onBackClick, + onSelectAllClick = onSelectAllClick, + ) + + is KeyMapAppBarState.ChildGroup -> { + val scope = rememberCoroutineScope() + val uniqueErrorText = stringResource(R.string.home_app_bar_group_name_unique_error) + var error: String? by rememberSaveable { mutableStateOf(null) } + var newName by remember { + mutableStateOf( + TextFieldValue( + state.groupName, + selection = TextRange(state.groupName.length), + ), + ) + } + var showDeleteGroupDialog by remember { mutableStateOf(false) } + + LaunchedEffect(state.groupName) { + showDeleteGroupDialog = false + error = null + + if (state.isEditingGroupName) { + if (state.isNewGroup) { + newName = TextFieldValue() + } else { + val endPosition = state.groupName.length + + newName = + TextFieldValue(state.groupName, selection = TextRange(endPosition)) + } + } + } + + if (showDeleteGroupDialog) { + DeleteGroupDialog( + groupName = state.groupName, + onDismissRequest = { showDeleteGroupDialog = false }, + onDeleteClick = onDeleteGroupClick, + ) + } + + ChildGroupAppBar( + modifier = modifier, + groupName = if (state.isEditingGroupName) { + newName + } else { + TextFieldValue(state.groupName) + }, + placeholder = state.groupName, + error = error, + onValueChange = { + newName = it + error = null + }, + onRenameClick = { + scope.launch { + if (!onRenameGroupClick(newName.text)) { + error = uniqueErrorText + } + } + }, + onBackClick = onBackClick, + onNewGroupClick = onNewGroupClick, + onEditClick = onEditGroupNameClick, + isEditingGroupName = state.isEditingGroupName, + subGroups = state.subGroups, + parentGroups = state.breadcrumbs, + onGroupClick = onGroupClick, + constraints = state.constraints, + constraintMode = state.constraintMode, + parentConstraintCount = state.parentConstraintCount, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + onConstraintModeChanged = onConstraintModeChanged, + onFixConstraintClick = onFixConstraintClick, + actions = { + AnimatedVisibility(!state.isEditingGroupName) { + AppBarActions( + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + showDeleteGroup = true, + onDeleteGroupClick = { + showDeleteGroupDialog = true + }, + ) + } + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun primaryAppBarColors(): TopAppBarColors { + return TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RootGroupAppBar( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.RootGroup, + scrollBehavior: TopAppBarScrollBehavior, + onTogglePausedClick: () -> Unit, + onSortClick: () -> Unit, + onFixWarningClick: (String) -> Unit, + onNewGroupClick: () -> Unit, + onGroupClick: (String) -> Unit, + actions: @Composable RowScope.() -> Unit, +) { + // This is taken from the AppBar color code. + val colorTransitionFraction by + remember(scrollBehavior) { + // derivedStateOf to prevent redundant recompositions when the content scrolls. + derivedStateOf { + val overlappingFraction = scrollBehavior.state.overlappedFraction + if (overlappingFraction > 0.01f) 1f else 0f + } + } + + val appBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors() + + val appBarContainerColor by animateColorAsState( + targetValue = lerp( + appBarColors.containerColor, + appBarColors.scrolledContainerColor, + FastOutLinearInEasing.transform(colorTransitionFraction), + ), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) + + Column(modifier) { + CenterAlignedTopAppBar( + scrollBehavior = scrollBehavior, + title = { + AppBarStatus( + isPaused = state.isPaused, + warnings = state.warnings, + onTogglePausedClick = onTogglePausedClick, + ) + }, + navigationIcon = { + IconButton(onClick = onSortClick) { + Icon( + Icons.AutoMirrored.Rounded.Sort, + contentDescription = stringResource(R.string.home_app_bar_sort), + ) + } + }, + actions = actions, + colors = appBarColors, + ) + + AnimatedVisibility(visible = state.warnings.isNotEmpty()) { + // Use separate Surfaces so the animation doesn't jump when they both disappear + // going into selection mode. + Surface(color = appBarContainerColor) { + HomeWarningList( + modifier = Modifier.padding(bottom = 8.dp), + warnings = (state as? KeyMapAppBarState.RootGroup)?.warnings ?: emptyList(), + onFixClick = onFixWarningClick, + ) + } + } + + Surface(color = appBarContainerColor) { + GroupRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + groups = state.subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + isSubgroups = false, + ) + } + } +} + +@Composable +private fun ChildGroupAppBar( + modifier: Modifier = Modifier, + groupName: TextFieldValue, + placeholder: String, + onValueChange: (TextFieldValue) -> Unit = {}, + error: String? = null, + onBackClick: () -> Unit = {}, + onEditClick: () -> Unit = {}, + onRenameClick: () -> Unit = {}, + isEditingGroupName: Boolean = false, + subGroups: List, + parentGroups: List, + onNewGroupClick: () -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, + constraints: List = emptyList(), + constraintMode: ConstraintMode, + parentConstraintCount: Int, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onConstraintModeChanged: (ConstraintMode) -> Unit = {}, + onFixConstraintClick: (Error) -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, +) { + // Make custom top app bar because the height can not be set to fix the text field error in. + Column { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Column { + Row( + Modifier + .statusBarsPadding() + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 8.dp) + .height(intrinsicSize = IntrinsicSize.Min), + verticalAlignment = Alignment.Top, + ) { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_app_bar_pop_group), + ) + } + + GroupNameRow( + modifier = Modifier.weight(1f), + value = groupName, + onValueChange = onValueChange, + placeholder = placeholder, + onRenameClick = onRenameClick, + error = error, + isEditing = isEditingGroupName, + onEditClick = onEditClick, + ) + + AnimatedVisibility(visible = !isEditingGroupName) { + actions() + } + } + + GroupConstraintRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + constraints = constraints, + mode = constraintMode, + parentConstraintCount = parentConstraintCount, + onFixConstraintClick = onFixConstraintClick, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + enabled = !isEditingGroupName, + ) + + Spacer(Modifier.height(8.dp)) + + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier.align(Alignment.End), + visible = constraints.size > 1, + ) { + Row { + RadioButtonText( + text = stringResource(R.string.constraint_mode_and), + isSelected = constraintMode == ConstraintMode.AND, + isEnabled = !isEditingGroupName, + onSelected = { + onConstraintModeChanged(ConstraintMode.AND) + }, + ) + + RadioButtonText( + text = stringResource(R.string.constraint_mode_or), + isSelected = constraintMode == ConstraintMode.OR, + isEnabled = !isEditingGroupName, + onSelected = { + onConstraintModeChanged(ConstraintMode.OR) + }, + ) + } + } + } + } + + Surface { + Column { + GroupBreadcrumbRow( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + groups = parentGroups, + onGroupClick = onGroupClick, + ) + + GroupRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + enabled = !isEditingGroupName, + isSubgroups = true, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectingAppBar( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.Selecting, + onBackClick: () -> Unit, + onSelectAllClick: () -> Unit, +) { + CenterAlignedTopAppBar( + modifier = modifier, + title = { + SelectedText(selectionCount = state.selectionCount) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_app_bar_cancel_selecting), + ) + } + }, + actions = { + OutlinedButton( + modifier = Modifier.padding(horizontal = 8.dp), + onClick = onSelectAllClick, + ) { + val text = if (state.isAllSelected) { + stringResource(R.string.home_app_bar_deselect_all) + } else { + stringResource(R.string.home_app_bar_select_all) + } + Text(text) + } + }, + colors = primaryAppBarColors(), + ) +} + +@Composable +private fun AppBarActions( + onHelpClick: () -> Unit, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + onExportClick: () -> Unit, + onImportClick: () -> Unit, + showDeleteGroup: Boolean = false, + onDeleteGroupClick: () -> Unit = {}, +) { + var expandedDropdown by rememberSaveable { mutableStateOf(false) } + + Row { + IconButton(onClick = onHelpClick) { + Icon( + Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = stringResource(R.string.home_app_bar_help), + ) + } + + IconButton(onClick = { expandedDropdown = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.home_app_bar_more), + ) + } + + AppBarDropdownMenu( + expanded = expandedDropdown, + onSettingsClick = { + expandedDropdown = false + onSettingsClick() + }, + onAboutClick = { + expandedDropdown = false + onAboutClick() + }, + onExportClick = { + expandedDropdown = false + onExportClick() + }, + onImportClick = { + expandedDropdown = false + onImportClick() + }, + onDismissRequest = { expandedDropdown = false }, + showDeleteGroup = showDeleteGroup, + onDeleteGroupClick = { + expandedDropdown = false + onDeleteGroupClick() + }, + ) + } +} + +@Composable +private fun GroupNameRow( + modifier: Modifier = Modifier, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit = {}, + placeholder: String, + isEditing: Boolean, + onRenameClick: () -> Unit, + onEditClick: () -> Unit = {}, + error: String? = null, +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isEditing) { + focusRequester.requestFocus() + } + + AnimatedContent(modifier = modifier, targetState = isEditing) { isEditing -> + Row( + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + val interactionSource = remember { MutableInteractionSource() } + + // Use a custom text field so the content padding can be customised. + BasicTextField( + modifier = Modifier + .focusRequester(focusRequester) + .height(IntrinsicSize.Max) + .then( + if (isEditing) { + Modifier.weight(1f) + } else { + Modifier.weight(1f, fill = false) + }, + ), + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.titleLarge.copy(color = LocalContentColor.current), + enabled = isEditing, + keyboardActions = KeyboardActions(onDone = { onRenameClick() }), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + showKeyboardOnFocus = true, + ), + singleLine = true, + maxLines = 1, + interactionSource = interactionSource, + ) { innerTextField -> + @OptIn(ExperimentalMaterial3Api::class) + OutlinedTextFieldDefaults.DecorationBox( + value = value.text, + placeholder = { + Text( + placeholder, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + color = OutlinedTextFieldDefaults.colors().disabledPlaceholderColor, + ) + }, + innerTextField = { + Box( + Modifier + .width(IntrinsicSize.Min) + .height(48.dp), + contentAlignment = Alignment.CenterStart, + ) { innerTextField() } + }, + singleLine = true, + colors = if (isEditing) { + OutlinedTextFieldDefaults.colors() + } else { + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + disabledTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + isError = error != null, + enabled = isEditing, + supportingText = if (error == null) { + null + } else { + { Text(error, maxLines = 1) } + }, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( + top = 0.dp, + bottom = 0.dp, + end = 4.dp, + start = 8.dp, + ), + ) + } + + if (isEditing) { + IconButton(onClick = onRenameClick) { + Icon( + Icons.Rounded.Done, + contentDescription = stringResource(R.string.home_app_bar_save_group_name), + ) + } + } else { + IconButton(onClick = onEditClick) { + Icon( + Icons.Rounded.Edit, + contentDescription = stringResource(R.string.home_app_bar_edit_group_name), + ) + } + } + } + } +} + +@Composable +private fun AppBarStatus( + isPaused: Boolean, + warnings: List, + onTogglePausedClick: () -> Unit, +) { + val pausedButtonContainerColor by animateColorAsState( + targetValue = if (isPaused || warnings.isNotEmpty()) { + MaterialTheme.colorScheme.errorContainer + } else { + LocalCustomColorsPalette.current.greenContainer + }, + ) + + val pausedButtonContentColor by animateColorAsState( + targetValue = if (isPaused || warnings.isNotEmpty()) { + MaterialTheme.colorScheme.onErrorContainer + } else { + LocalCustomColorsPalette.current.onGreenContainer + }, + ) + + FilledTonalButton( + modifier = Modifier.widthIn(min = 8.dp), + onClick = onTogglePausedClick, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = pausedButtonContainerColor, + contentColor = pausedButtonContentColor, + ), + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + val buttonIcon: ImageVector + val buttonText: String + + if (isPaused) { + buttonIcon = Icons.Rounded.PauseCircleOutline + buttonText = stringResource(R.string.home_app_bar_status_paused) + } else if (warnings.isNotEmpty()) { + buttonIcon = Icons.Rounded.ErrorOutline + buttonText = pluralStringResource( + R.plurals.home_app_bar_status_warnings, + warnings.size, + warnings.size, + ) + } else { + buttonIcon = Icons.Rounded.PlayCircleOutline + buttonText = stringResource(R.string.home_app_bar_status_running) + } + + val transition = + slideInVertically { height -> -height } + fadeIn() togetherWith slideOutVertically { height -> height } + fadeOut() + + AnimatedContent(targetState = buttonIcon, transitionSpec = { transition }) { icon -> + Icon(icon, contentDescription = null) + } + + AnimatedContent( + targetState = buttonText, + transitionSpec = { transition }, + ) { text -> + Row { + Spacer(modifier = Modifier.width(4.dp)) + Text(text) + } + } + } +} + +@Composable +private fun SelectedText(modifier: Modifier = Modifier, selectionCount: Int) { + Row(modifier) { + AnimatedContent( + selectionCount, + transitionSpec = { + selectedTextTransition( + targetState, + initialState, + ) + }, + ) { selectionCount -> + Text(selectionCount.toString()) + } + + Spacer(Modifier.width(4.dp)) + + Text(stringResource(R.string.selection_count)) + } +} + +private fun selectedTextTransition( + targetState: Int, + initialState: Int, +): ContentTransform { + return slideInVertically { height -> + if (targetState > initialState) { + -height + } else { + height + } + } + fadeIn() togetherWith slideOutVertically { height -> + if (targetState > initialState) { + height + } else { + -height + } + } + fadeOut() +} + +@Composable +private fun AppBarDropdownMenu( + expanded: Boolean, + onSettingsClick: () -> Unit = {}, + onAboutClick: () -> Unit = {}, + onExportClick: () -> Unit = {}, + onImportClick: () -> Unit = {}, + onDismissRequest: () -> Unit = {}, + showDeleteGroup: Boolean = false, + onDeleteGroupClick: () -> Unit = {}, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + if (showDeleteGroup) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.Rounded.Delete, contentDescription = null) }, + text = { Text(stringResource(R.string.home_menu_delete_group)) }, + onClick = onDeleteGroupClick, + ) + } + + DropdownMenuItem( + leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) }, + text = { Text(stringResource(R.string.home_menu_settings)) }, + onClick = onSettingsClick, + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Rounded.IosShare, contentDescription = null) }, + text = { Text(stringResource(R.string.home_menu_export)) }, + onClick = onExportClick, + ) + DropdownMenuItem( + leadingIcon = { Icon(KeyMapperIcons.Import, contentDescription = null) }, + text = { Text(stringResource(R.string.home_menu_import)) }, + onClick = onImportClick, + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Rounded.Info, contentDescription = null) }, + text = { Text(stringResource(R.string.home_menu_about)) }, + onClick = onAboutClick, + ) + } +} + +@Composable +private fun constraintsSampleList(): List { + val ctx = LocalContext.current + + return listOf( + ComposeChipModel.Normal( + id = "1", + text = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ComposeChipModel.Normal( + id = "2", + text = "Key Mapper is open", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ComposeChipModel.Error( + id = "2", + text = "Key Mapper not found", + error = Error.AppNotFound(Constants.PACKAGE_NAME), + ), + ) +} + +@Composable +private fun groupSampleList(): List { + val ctx = LocalContext.current + + return listOf( + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "2", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Very very very very very long name", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + parentConstraintCount = 1, + constraintMode = ConstraintMode.AND, + breadcrumbs = groupSampleList(), + isEditingGroupName = false, + isNewGroup = false, + ) + KeyMapperTheme { + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupDarkPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Short name", + subGroups = groupSampleList(), + constraints = emptyList(), + constraintMode = ConstraintMode.AND, + parentConstraintCount = 0, + breadcrumbs = emptyList(), + isEditingGroupName = false, + isNewGroup = false, + ) + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupEditingPreview() { + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme { + ChildGroupAppBar( + groupName = TextFieldValue(""), + placeholder = "Untitled group 23", + error = stringResource(R.string.home_app_bar_group_name_unique_error), + isEditingGroupName = true, + subGroups = emptyList(), + parentGroups = emptyList(), + constraints = emptyList(), + constraintMode = ConstraintMode.AND, + parentConstraintCount = 1, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupEditingDarkPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Untitled group 23", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + parentConstraintCount = 3, + constraintMode = ConstraintMode.AND, + breadcrumbs = emptyList(), + isEditingGroupName = true, + isNewGroup = true, + ) + + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar( + state = state, + ) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupErrorPreview() { + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme { + ChildGroupAppBar( + groupName = TextFieldValue("Untitled group 23"), + placeholder = "Untitled group 23", + error = stringResource(R.string.home_app_bar_group_name_unique_error), + isEditingGroupName = true, + subGroups = emptyList(), + parentGroups = emptyList(), + constraints = emptyList(), + constraintMode = ConstraintMode.AND, + parentConstraintCount = 0, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun KeyMapsRunningPreview() { + val state = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = false, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun HomeStatePausedPreview() { + val state = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = true, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun HomeStateWarningsPreview() { + val warnings = listOf( + HomeWarningListItem( + id = "0", + text = stringResource(R.string.home_error_accessibility_service_is_disabled), + ), + HomeWarningListItem( + id = "1", + text = stringResource(R.string.home_error_is_battery_optimised), + ), + ) + + val state = + KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = warnings, + isPaused = true, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun HomeStateWarningsDarkPreview() { + val warnings = listOf( + HomeWarningListItem( + id = "0", + text = stringResource(R.string.home_error_accessibility_service_is_disabled), + ), + HomeWarningListItem( + id = "1", + text = stringResource(R.string.home_error_is_battery_optimised), + ), + ) + + val state = + KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = warnings, + isPaused = true, + ) + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun HomeStateSelectingPreview() { + val state = KeyMapAppBarState.Selecting( + selectionCount = 4, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + isAllSelected = false, + groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun HomeStateSelectingDisabledPreview() { + val state = KeyMapAppBarState.Selecting( + selectionCount = 4, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + isAllSelected = true, + groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt new file mode 100644 index 0000000000..c78a40dac2 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -0,0 +1,318 @@ +package io.github.sds100.keymapper.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.IosShare +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +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.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.groups.GroupBreadcrumbRow +import io.github.sds100.keymapper.groups.GroupListItemModel +import io.github.sds100.keymapper.groups.GroupRow +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectionBottomSheet( + modifier: Modifier = Modifier, + enabled: Boolean, + groups: List, + breadcrumbs: List, + selectedKeyMapsEnabled: SelectedKeyMapsEnabled, + onDuplicateClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {}, + onExportClick: () -> Unit = {}, + onEnabledKeyMapsChange: (Boolean) -> Unit = {}, + onNewGroupClick: () -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, + showThisGroup: Boolean = false, + onThisGroupClick: () -> Unit = {}, +) { + Surface( + modifier = modifier + .widthIn(max = BottomSheetDefaults.SheetMaxWidth) + .fillMaxWidth() + .navigationBarsPadding(), + shadowElevation = 5.dp, + shape = BottomSheetDefaults.ExpandedShape, + tonalElevation = BottomSheetDefaults.Elevation, + color = BottomSheetDefaults.ContainerColor, + ) { + Column { + Row( + modifier = Modifier + .height(intrinsicSize = IntrinsicSize.Min), + ) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(state = rememberScrollState()), + ) { + Spacer(Modifier.width(16.dp)) + + SelectionButton( + text = stringResource(R.string.home_multi_select_duplicate), + icon = Icons.Rounded.ContentCopy, + enabled = enabled, + onClick = onDuplicateClick, + ) + + SelectionButton( + text = stringResource(R.string.home_multi_select_delete), + icon = Icons.Rounded.DeleteOutline, + enabled = enabled, + onClick = onDeleteClick, + ) + + SelectionButton( + text = stringResource(R.string.home_multi_select_export), + icon = Icons.Rounded.IosShare, + enabled = enabled, + onClick = onExportClick, + ) + + Spacer(Modifier.width(16.dp)) + } + + VerticalDivider( + modifier = Modifier.padding(vertical = 8.dp), + ) + + KeyMapsEnabledSwitch( + modifier = Modifier + .padding(horizontal = 16.dp) + .width(IntrinsicSize.Max), + state = selectedKeyMapsEnabled, + enabled = enabled, + onCheckedChange = onEnabledKeyMapsChange, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.home_move_to_group), + style = MaterialTheme.typography.labelLarge, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + GroupBreadcrumbRow( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp), + groups = breadcrumbs, + onGroupClick = onGroupClick, + enabled = true, + ) + + GroupRow( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + groups = groups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + enabled = enabled, + showThisGroupButton = showThisGroup, + onThisGroupClick = onThisGroupClick, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Composable +private fun SelectionButton( + modifier: Modifier = Modifier, + text: String, + icon: ImageVector, + enabled: Boolean, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Column( + modifier + .padding(4.dp) + .width(72.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + enabled = enabled, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconButton(onClick = onClick, interactionSource = interactionSource, enabled = enabled) { + Icon(icon, text) + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun KeyMapsEnabledSwitch( + modifier: Modifier = Modifier, + state: SelectedKeyMapsEnabled, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Column( + modifier.padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Switch( + checked = state == SelectedKeyMapsEnabled.ALL, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + val text = when (state) { + SelectedKeyMapsEnabled.ALL -> stringResource(R.string.home_enabled_key_maps_enabled) + SelectedKeyMapsEnabled.NONE -> stringResource(R.string.home_enabled_key_maps_disabled) + SelectedKeyMapsEnabled.MIXED -> stringResource(R.string.home_enabled_key_maps_mixed) + } + + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } +} + +@Preview +@Composable +private fun PreviewEmptyGroups() { + KeyMapperTheme { + SelectionBottomSheet( + enabled = true, + groups = emptyList(), + breadcrumbs = emptyList(), + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL, + onDuplicateClick = {}, + onDeleteClick = {}, + onExportClick = {}, + onEnabledKeyMapsChange = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewGroups() { + val ctx = LocalContext.current + + KeyMapperTheme { + SelectionBottomSheet( + enabled = true, + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "2", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + ), + breadcrumbs = listOf( + GroupListItemModel( + uid = "2", + name = "Key Mapper", + icon = null, + ), + ), + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL, + onDuplicateClick = {}, + onDeleteClick = {}, + onExportClick = {}, + onEnabledKeyMapsChange = {}, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/home/ShowHomeScreenAlertsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/home/ShowHomeScreenAlertsUseCase.kt index 4a50cd74fb..84cf3a0fb2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/ShowHomeScreenAlertsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/ShowHomeScreenAlertsUseCase.kt @@ -2,7 +2,7 @@ package io.github.sds100.keymapper.home import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.system.accessibility.ServiceAdapter import io.github.sds100.keymapper.system.accessibility.ServiceState import io.github.sds100.keymapper.system.permissions.Permission @@ -18,7 +18,7 @@ class ShowHomeScreenAlertsUseCaseImpl( private val preferences: PreferenceRepository, private val permissions: PermissionAdapter, private val accessibilityServiceAdapter: ServiceAdapter, - private val pauseMappingsUseCase: PauseMappingsUseCase, + private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, ) : ShowHomeScreenAlertsUseCase { override val hideAlerts: Flow = preferences.get(Keys.hideHomeScreenAlerts).map { it == true } @@ -27,7 +27,7 @@ class ShowHomeScreenAlertsUseCaseImpl( permissions.isGrantedFlow(Permission.IGNORE_BATTERY_OPTIMISATION) .map { !it } // if granted then battery is NOT optimised - override val areKeyMapsPaused: Flow = pauseMappingsUseCase.isPaused + override val areKeyMapsPaused: Flow = pauseKeyMapsUseCase.isPaused override val isLoggingEnabled: Flow = preferences.get(Keys.log).map { it == true } @@ -46,7 +46,7 @@ class ShowHomeScreenAlertsUseCaseImpl( } override fun resumeMappings() { - pauseMappingsUseCase.resume() + pauseKeyMapsUseCase.resume() } override fun disableLogging() { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/PauseMappingsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt similarity index 91% rename from app/src/main/java/io/github/sds100/keymapper/mappings/PauseMappingsUseCase.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt index 73f05d6125..3214f13d19 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/PauseMappingsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt @@ -11,10 +11,10 @@ import timber.log.Timber * Created by sds100 on 16/04/2021. */ -class PauseMappingsUseCaseImpl( +class PauseKeyMapsUseCaseImpl( private val preferenceRepository: PreferenceRepository, private val mediaAdapter: MediaAdapter, -) : PauseMappingsUseCase { +) : PauseKeyMapsUseCase { override val isPaused: Flow = preferenceRepository.get(Keys.mappingsPaused).map { it ?: false } @@ -31,7 +31,7 @@ class PauseMappingsUseCaseImpl( } } -interface PauseMappingsUseCase { +interface PauseKeyMapsUseCase { val isPaused: Flow fun pause() fun resume() diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt index 59fea4aabb..be3ad1e6c7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt @@ -31,11 +31,14 @@ class ConfigKeyMapFragment : Fragment() { // only load the keymap if opening this fragment for the first time if (savedInstanceState == null) { - args.keymapUid.let { - if (it == null) { - viewModel.loadNewKeymap(args.newFloatingButtonTriggerKey) + args.keyMapUid.also { keyMapUid -> + if (keyMapUid == null) { + viewModel.loadNewKeymap( + args.newFloatingButtonTriggerKey, + groupUid = args.groupUid, + ) } else { - viewModel.loadKeyMap(it) + viewModel.loadKeyMap(keyMapUid) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 466f356a40..f5b0dcdeae 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -803,11 +803,11 @@ class ConfigKeyMapUseCaseController( } if (data is ActionData.AnswerCall) { - addConstraint(Constraint.PhoneRinging) + addConstraint(Constraint.PhoneRinging()) } if (data is ActionData.EndCall) { - addConstraint(Constraint.InPhoneCall) + addConstraint(Constraint.InPhoneCall()) } return Action( @@ -838,8 +838,8 @@ class ConfigKeyMapUseCaseController( originalKeyMap = keyMap } - override fun loadNewKeyMap() { - val keyMap = KeyMap() + override fun loadNewKeyMap(groupUid: String?) { + val keyMap = KeyMap(groupUid = groupUid) this.keyMap.update { State.Data(keyMap) } originalKeyMap = keyMap } @@ -997,7 +997,7 @@ interface ConfigKeyMapUseCase : GetDefaultKeyMapOptionsUseCase { fun restoreState(keyMap: KeyMap) suspend fun loadKeyMap(uid: String) - fun loadNewKeyMap() + fun loadNewKeyMap(groupUid: String?) fun setParallelTriggerMode() fun setSequenceTriggerMode() diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt index 2cb66892c1..010ba017b0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt @@ -99,8 +99,8 @@ class ConfigKeyMapViewModel( config.restoreState(keyMap) } - fun loadNewKeymap(floatingButtonUid: String? = null) { - config.loadNewKeyMap() + fun loadNewKeymap(floatingButtonUid: String? = null, groupUid: String?) { + config.loadNewKeyMap(groupUid) if (floatingButtonUid != null) { viewModelScope.launch { config.addFloatingButtonTriggerKey(floatingButtonUid) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutScreen.kt index 75fceb6312..07c63bd78e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutScreen.kt @@ -48,11 +48,11 @@ fun CreateKeyMapShortcutScreen( viewModel: CreateKeyMapShortcutViewModel, finishActivity: () -> Unit = {}, ) { - val listItems by viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() CreateKeyMapShortcutScreen( modifier = modifier, - listItems = listItems, + listItems = state.listItems, showShortcutNameDialog = viewModel.showShortcutNameDialog, dismissShortcutNameDialog = { viewModel.showShortcutNameDialog = null }, onShortcutNameResult = { name -> @@ -92,6 +92,7 @@ private fun CreateKeyMapShortcutScreen( ) } + // TODO allow navigating between groups and hide the FAB. Scaffold( modifier = modifier, bottomBar = { @@ -113,7 +114,7 @@ private fun CreateKeyMapShortcutScreen( text = stringResource(R.string.caption_create_keymap_shortcut), ) - KeyMapListScreen( + KeyMapList( modifier = Modifier.fillMaxSize(), footerText = stringResource(R.string.create_key_map_shortcut_footer), listItems = listItems, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt index fc51705bf1..102e6582f0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt @@ -9,12 +9,19 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import io.github.sds100.keymapper.actions.ActionErrorSnapshot import io.github.sds100.keymapper.actions.ActionUiHelper +import io.github.sds100.keymapper.constraints.ConstraintErrorSnapshot +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.constraints.ConstraintUiHelper +import io.github.sds100.keymapper.groups.GroupListItemModel import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerErrorSnapshot import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.mapData import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.TintType +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -36,9 +43,21 @@ class CreateKeyMapShortcutViewModel( ) : ViewModel(), ResourceProvider by resourceProvider { private val actionUiHelper = ActionUiHelper(listKeyMaps, resourceProvider) + private val constraintUiHelper = ConstraintUiHelper( + listKeyMaps, + resourceProvider, + ) private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) - private val _state = MutableStateFlow>>(State.Loading) + private val initialState = KeyMapListState( + appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = false, + ), + listItems = State.Loading, + ) + private val _state: MutableStateFlow = MutableStateFlow(initialState) val state = _state.asStateFlow() private val _returnIntentResult = MutableSharedFlow() @@ -50,35 +69,94 @@ class CreateKeyMapShortcutViewModel( init { viewModelScope.launch { combine( - listKeyMaps.keyMapList, + listKeyMaps.keyMapGroup, listKeyMaps.showDeviceDescriptors, listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, - ) { keyMapListState, showDeviceDescriptors, triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot -> - _state.value = keyMapListState.mapData { keyMapList -> - keyMapList.map { keyMap -> - val listItem = - listItemCreator.create( - keyMap, - showDeviceDescriptors, - triggerErrorSnapshot, - actionErrorSnapshot, - constraintErrorSnapshot, - ) - - KeyMapListItemModel(isSelected = false, listItem) - } - } + ) { keyMapGroup, showDeviceDescriptors, triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot -> + _state.value = buildState( + keyMapGroup, + showDeviceDescriptors, + triggerErrorSnapshot, + actionErrorSnapshot, + constraintErrorSnapshot, + ) }.collect() } } + private fun buildState( + keyMapGroup: KeyMapGroup, + showDeviceDescriptors: Boolean, + triggerErrorSnapshot: TriggerErrorSnapshot, + actionErrorSnapshot: ActionErrorSnapshot, + constraintErrorSnapshot: ConstraintErrorSnapshot, + ): KeyMapListState { + val listItemsState = keyMapGroup.keyMaps.mapData { list -> + list.map { + val content = listItemCreator.build( + it, + showDeviceDescriptors, + triggerErrorSnapshot, + actionErrorSnapshot, + constraintErrorSnapshot, + ) + + KeyMapListItemModel(isSelected = false, content) + } + } + + val subGroupListItems = keyMapGroup.subGroups.map { group -> + var icon: ComposeIconInfo? = null + + val constraint = group.constraintState.constraints.firstOrNull() + if (constraint != null) { + icon = constraintUiHelper.getIcon(constraint) + } + + GroupListItemModel( + uid = group.uid, + name = group.name, + icon = icon, + ) + } + + val parentGroupListItems = keyMapGroup.parents.map { group -> + GroupListItemModel( + uid = group.uid, + name = group.name, + icon = null, + ) + } + + val appBarState = if (keyMapGroup.group == null) { + KeyMapAppBarState.RootGroup( + subGroups = subGroupListItems, + warnings = emptyList(), + isPaused = false, + ) + } else { + KeyMapAppBarState.ChildGroup( + groupName = keyMapGroup.group.name, + subGroups = subGroupListItems, + constraints = emptyList(), + constraintMode = ConstraintMode.AND, + breadcrumbs = parentGroupListItems, + isEditingGroupName = false, + isNewGroup = false, + parentConstraintCount = keyMapGroup.parents.sumOf { it.constraintState.constraints.size }, + ) + } + + return KeyMapListState(appBarState, listItemsState) + } + fun onKeyMapCardClick(uid: String) { viewModelScope.launch { - val state = listKeyMaps.keyMapList.first { it is State.Data } + val state = listKeyMaps.keyMapGroup.first { it.keyMaps is State.Data } - if (state !is State.Data) return@launch + if (state.keyMaps !is State.Data) return@launch configKeyMapUseCase.loadKeyMap(uid) configKeyMapUseCase.setTriggerFromOtherAppsEnabled(true) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt index eccd14761d..a03608bc2b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.mappings.keymaps import android.view.KeyEvent import io.github.sds100.keymapper.actions.Action import io.github.sds100.keymapper.actions.ActionData -import io.github.sds100.keymapper.actions.KeymapActionEntityMapper +import io.github.sds100.keymapper.actions.ActionEntityMapper import io.github.sds100.keymapper.actions.canBeHeldDown import io.github.sds100.keymapper.constraints.ConstraintEntityMapper import io.github.sds100.keymapper.constraints.ConstraintModeEntityMapper @@ -30,6 +30,7 @@ data class KeyMap( val actionList: List = emptyList(), val constraintState: ConstraintState = ConstraintState(), val isEnabled: Boolean = true, + val groupUid: String? = null, ) { val showToast: Boolean @@ -105,11 +106,11 @@ fun KeyMap.requiresImeKeyEventForwardingInPhoneCall(triggerKey: TriggerKey): Boo } object KeyMapEntityMapper { - suspend fun fromEntity( + fun fromEntity( entity: KeyMapEntity, floatingButtons: List, ): KeyMap { - val actionList = entity.actionList.mapNotNull { KeymapActionEntityMapper.fromEntity(it) } + val actionList = entity.actionList.mapNotNull { ActionEntityMapper.fromEntity(it) } val constraintList = entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet() @@ -123,11 +124,12 @@ object KeyMapEntityMapper { actionList = actionList, constraintState = ConstraintState(constraintList, constraintMode), isEnabled = entity.isEnabled, + groupUid = entity.groupUid, ) } fun toEntity(keyMap: KeyMap, dbId: Long): KeyMapEntity { - val actionEntityList = KeymapActionEntityMapper.toEntity(keyMap) + val actionEntityList = ActionEntityMapper.toEntity(keyMap) return KeyMapEntity( id = dbId, @@ -141,6 +143,7 @@ object KeyMapEntityMapper { constraintMode = ConstraintModeEntityMapper.toEntity(keyMap.constraintState.mode), isEnabled = keyMap.isEnabled, uid = keyMap.uid, + groupUid = keyMap.groupUid, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt new file mode 100644 index 0000000000..12b3030e65 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt @@ -0,0 +1,35 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.groups.GroupListItemModel +import io.github.sds100.keymapper.home.HomeWarningListItem +import io.github.sds100.keymapper.home.SelectedKeyMapsEnabled +import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel + +sealed class KeyMapAppBarState { + data class RootGroup( + val subGroups: List = emptyList(), + val warnings: List = emptyList(), + val isPaused: Boolean = false, + ) : KeyMapAppBarState() + + data class ChildGroup( + val groupName: String, + val constraints: List, + val constraintMode: ConstraintMode, + val parentConstraintCount: Int, + val subGroups: List, + val breadcrumbs: List, + val isEditingGroupName: Boolean, + val isNewGroup: Boolean, + ) : KeyMapAppBarState() + + data class Selecting( + val selectionCount: Int, + val selectedKeyMapsEnabled: SelectedKeyMapsEnabled, + val isAllSelected: Boolean, + val groups: List, + val breadcrumbs: List, + val showThisGroup: Boolean, + ) : KeyMapAppBarState() +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapGroup.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapGroup.kt new file mode 100644 index 0000000000..de2910ec78 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapGroup.kt @@ -0,0 +1,11 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import io.github.sds100.keymapper.groups.Group +import io.github.sds100.keymapper.util.State + +data class KeyMapGroup( + val group: Group?, + val subGroups: List, + val parents: List, + val keyMaps: State>, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 85fa04a7d5..2c46bb79d7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt @@ -7,6 +7,7 @@ import io.github.sds100.keymapper.R import io.github.sds100.keymapper.actions.ActionErrorSnapshot import io.github.sds100.keymapper.actions.ActionUiHelper import io.github.sds100.keymapper.constraints.ConstraintErrorSnapshot +import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.constraints.ConstraintUiHelper import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.FingerprintGestureType @@ -42,7 +43,7 @@ class KeyMapListItemCreator( private val actionUiHelper = ActionUiHelper(displayMapping, resourceProvider) - fun create( + fun build( keyMap: KeyMap, showDeviceDescriptors: Boolean, triggerErrorSnapshot: TriggerErrorSnapshot, @@ -66,7 +67,8 @@ class KeyMapListItemCreator( val options = getTriggerOptionLabels(keyMap.trigger) val actionChipList = getActionChipList(keyMap, showDeviceDescriptors, actionErrorSnapshot) - val constraintChipList = getConstraintChipList(keyMap, constraintErrorSnapshot) + val constraintChipList = + buildConstraintChipList(keyMap.constraintState, constraintErrorSnapshot) val extraInfo = buildString { append(createExtraInfoString(keyMap, actionChipList, constraintChipList)) @@ -157,11 +159,11 @@ class KeyMapListItemCreator( } }.toList() - private fun getConstraintChipList( - keyMap: KeyMap, + fun buildConstraintChipList( + constraintState: ConstraintState, errorSnapshot: ConstraintErrorSnapshot, ): List = sequence { - for (constraint in keyMap.constraintState.constraints) { + for (constraint in constraintState.constraints) { val text: String = constraintUiHelper.getTitle(constraint) val icon: ComposeIconInfo = constraintUiHelper.getIcon(constraint) val error: Error? = errorSnapshot.getError(constraint) @@ -173,7 +175,7 @@ class KeyMapListItemCreator( icon = icon, ) } else { - ComposeChipModel.Error(constraint.uid, text, error) + ComposeChipModel.Error(constraint.uid, text, error, error.isFixable) } yield(chip) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListScreen.kt index ef50f07cc6..36bfa61983 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -24,9 +23,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowForward import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Error import androidx.compose.material.icons.outlined.FlashlightOn -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -37,7 +34,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -51,12 +47,11 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme @@ -66,38 +61,13 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.CompactChip import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.ErrorCompactChip @Composable -fun KeyMapListScreen( - modifier: Modifier = Modifier, - viewModel: KeyMapListViewModel, - lazyListState: LazyListState, -) { - val listItems by viewModel.state.collectAsStateWithLifecycle() - val isSelectable by viewModel.isSelectable.collectAsStateWithLifecycle() - - KeyMapListScreen( - modifier = modifier, - lazyListState = lazyListState, - listItems = listItems, - footerText = if (isSelectable) { - null - } else { - stringResource(R.string.home_key_map_list_footer_text) - }, - isSelectable = isSelectable, - onClickKeyMap = viewModel::onKeyMapCardClick, - onLongClickKeyMap = viewModel::onKeyMapCardLongClick, - onSelectedChange = viewModel::onKeyMapSelectedChanged, - onFixClick = viewModel::onFixClick, - onTriggerErrorClick = viewModel::onFixTriggerError, - ) -} - -@Composable -fun KeyMapListScreen( +fun KeyMapList( modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), listItems: State>, @@ -108,21 +78,22 @@ fun KeyMapListScreen( onSelectedChange: (String, Boolean) -> Unit = { _, _ -> }, onFixClick: (Error) -> Unit = {}, onTriggerErrorClick: (TriggerError) -> Unit = {}, + bottomListPadding: Dp = 100.dp, ) { - Surface(modifier = modifier) { - when (listItems) { - is State.Loading -> { - Box { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } + when (listItems) { + is State.Loading -> { + Surface(modifier = modifier) { + LoadingList(modifier = Modifier.fillMaxSize()) } + } - is State.Data -> { + is State.Data -> { + Surface(modifier = modifier) { if (listItems.data.isEmpty()) { - EmptyKeyMapList(modifier = modifier) + EmptyKeyMapList(modifier = Modifier.fillMaxSize()) } else { - KeyMapList( - modifier, + LoadedKeyMapList( + Modifier.fillMaxSize(), lazyListState, listItems.data, footerText, @@ -132,6 +103,7 @@ fun KeyMapListScreen( onSelectedChange, onFixClick, onTriggerErrorClick, + bottomListPadding, ) } } @@ -139,6 +111,13 @@ fun KeyMapListScreen( } } +@Composable +private fun LoadingList(modifier: Modifier = Modifier) { + Box(modifier) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } +} + @Composable private fun EmptyKeyMapList(modifier: Modifier = Modifier) { Box(modifier) { @@ -162,7 +141,7 @@ private fun EmptyKeyMapList(modifier: Modifier = Modifier) { } @Composable -private fun KeyMapList( +private fun LoadedKeyMapList( modifier: Modifier = Modifier, lazyListState: LazyListState, listItems: List, @@ -173,6 +152,7 @@ private fun KeyMapList( onSelectedChange: (String, Boolean) -> Unit, onFixClick: (Error) -> Unit, onTriggerErrorClick: (TriggerError) -> Unit, + bottomListPadding: Dp, ) { val haptics = LocalHapticFeedback.current @@ -211,7 +191,7 @@ private fun KeyMapList( // Give some space at the end of the list so that the FAB doesn't block the items. item { - Spacer(Modifier.height(100.dp)) + Spacer(Modifier.height(bottomListPadding)) } } } @@ -293,7 +273,7 @@ private fun KeyMapListItem( ), ) { for (error in model.content.triggerErrors) { - ErrorChip( + ErrorCompactChip( onClick = { onTriggerErrorClick(error) }, text = getTriggerErrorMessage(error), enabled = error.isFixable, @@ -487,7 +467,7 @@ private fun ActionConstraintChip( ) } - is ComposeChipModel.Error -> ErrorChip( + is ComposeChipModel.Error -> ErrorCompactChip( onClick = { onFixClick(model.error) }, model.text, model.isFixable, @@ -495,87 +475,6 @@ private fun ActionConstraintChip( } } -@Composable -private fun ErrorChip( - onClick: () -> Unit, - text: String, - enabled: Boolean, -) { - CompactChip( - text = text, - icon = { - Icon( - modifier = Modifier.fillMaxHeight(), - imageVector = Icons.Outlined.Error, - contentDescription = null, - ) - }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - onClick = onClick, - enabled = enabled, - ) -} - -@Composable -private fun CompactChip( - modifier: Modifier = Modifier, - text: String, - icon: (@Composable () -> Unit)? = null, - containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, - contentColor: Color = MaterialTheme.colorScheme.onSurface, - onClick: (() -> Unit)? = null, - enabled: Boolean = false, -) { - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - if (onClick == null || !enabled) { - Surface( - modifier = modifier.height(chipHeight), - color = containerColor, - shape = AssistChipDefaults.shape, - ) { - CompactChipContent(icon, text, contentColor) - } - } else { - Surface( - modifier = modifier.height(chipHeight), - color = containerColor, - shape = AssistChipDefaults.shape, - onClick = onClick, - ) { - CompactChipContent(icon, text, contentColor) - } - } - } -} - -@Composable -private fun CompactChipContent( - icon: @Composable (() -> Unit)?, - text: String, - contentColor: Color, -) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (icon != null) { - icon() - Spacer(Modifier.width(4.dp)) - } - - Text( - text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelLarge, - color = contentColor, - ) - } -} - @Composable private fun getTriggerErrorMessage(error: TriggerError): String { return when (error) { @@ -736,7 +635,7 @@ private fun sampleList(): List { @Composable private fun ListPreview() { KeyMapperTheme { - KeyMapListScreen(modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList())) + KeyMapList(modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList())) } } @@ -744,7 +643,7 @@ private fun ListPreview() { @Composable private fun SelectableListPreview() { KeyMapperTheme { - KeyMapListScreen( + KeyMapList( modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList()), isSelectable = true, @@ -756,7 +655,7 @@ private fun SelectableListPreview() { @Composable private fun EmptyPreview() { KeyMapperTheme { - KeyMapListScreen(modifier = Modifier.fillMaxSize(), listItems = State.Data(emptyList())) + KeyMapList(modifier = Modifier.fillMaxSize(), listItems = State.Data(emptyList())) } } @@ -764,6 +663,6 @@ private fun EmptyPreview() { @Composable private fun LoadingPreview() { KeyMapperTheme { - KeyMapListScreen(modifier = Modifier.fillMaxSize(), listItems = State.Loading) + KeyMapList(modifier = Modifier.fillMaxSize(), listItems = State.Loading) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListState.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListState.kt new file mode 100644 index 0000000000..9f79721767 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListState.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel +import io.github.sds100.keymapper.util.State + +data class KeyMapListState( + val appBarState: KeyMapAppBarState, + val listItems: State>, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt index d14043c6fb..318b40f42b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt @@ -3,62 +3,118 @@ package io.github.sds100.keymapper.mappings.keymaps import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.actions.ActionErrorSnapshot +import io.github.sds100.keymapper.backup.BackupRestoreMappingsUseCase +import io.github.sds100.keymapper.backup.ImportExportState +import io.github.sds100.keymapper.backup.RestoreType +import io.github.sds100.keymapper.constraints.ConstraintErrorSnapshot +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.constraints.ConstraintUiHelper +import io.github.sds100.keymapper.groups.Group +import io.github.sds100.keymapper.groups.GroupFamily +import io.github.sds100.keymapper.groups.GroupListItemModel +import io.github.sds100.keymapper.home.HomeWarningListItem +import io.github.sds100.keymapper.home.SelectedKeyMapsEnabled +import io.github.sds100.keymapper.home.ShowHomeScreenAlertsUseCase +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel import io.github.sds100.keymapper.mappings.keymaps.trigger.SetupGuiKeyboardState import io.github.sds100.keymapper.mappings.keymaps.trigger.SetupGuiKeyboardUseCase import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerErrorSnapshot import io.github.sds100.keymapper.sorting.SortKeyMapsUseCase +import io.github.sds100.keymapper.sorting.SortViewModel +import io.github.sds100.keymapper.system.accessibility.ServiceState import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.dataOrNull +import io.github.sds100.keymapper.util.getFullMessage import io.github.sds100.keymapper.util.ifIsData import io.github.sds100.keymapper.util.mapData +import io.github.sds100.keymapper.util.onFailure +import io.github.sds100.keymapper.util.onSuccess +import io.github.sds100.keymapper.util.ui.DialogResponse import io.github.sds100.keymapper.util.ui.MultiSelectProvider import io.github.sds100.keymapper.util.ui.NavDestination +import io.github.sds100.keymapper.util.ui.NavigateEvent import io.github.sds100.keymapper.util.ui.NavigationViewModel import io.github.sds100.keymapper.util.ui.NavigationViewModelImpl +import io.github.sds100.keymapper.util.ui.PopupUi import io.github.sds100.keymapper.util.ui.PopupViewModel import io.github.sds100.keymapper.util.ui.PopupViewModelImpl import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.SelectionState import io.github.sds100.keymapper.util.ui.ViewModelHelper +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.util.ui.navigate +import io.github.sds100.keymapper.util.ui.showPopup import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@OptIn(ExperimentalCoroutinesApi::class) class KeyMapListViewModel( private val coroutineScope: CoroutineScope, private val listKeyMaps: ListKeyMapsUseCase, resourceProvider: ResourceProvider, - private val multiSelectProvider: MultiSelectProvider, private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val sortKeyMaps: SortKeyMapsUseCase, + private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, + private val pauseKeyMaps: PauseKeyMapsUseCase, + private val backupRestore: BackupRestoreMappingsUseCase, + ) : PopupViewModel by PopupViewModelImpl(), ResourceProvider by resourceProvider, NavigationViewModel by NavigationViewModelImpl() { + private companion object { + const val ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM = "accessibility_service_disabled" + const val ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM = "accessibility_service_crashed" + const val ID_BATTERY_OPTIMISATION_LIST_ITEM = "battery_optimised" + const val ID_LOGGING_ENABLED_LIST_ITEM = "logging_enabled" + } + + val sortViewModel = SortViewModel(coroutineScope, sortKeyMaps) + var showSortBottomSheet by mutableStateOf(false) + + val multiSelectProvider: MultiSelectProvider = MultiSelectProvider() + private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) + private val constraintUiHelper = ConstraintUiHelper(listKeyMaps, resourceProvider) - private val _state = MutableStateFlow>>(State.Loading) + private val initialState = KeyMapListState( + appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = false, + ), + listItems = State.Loading, + ) + private val _state: MutableStateFlow = MutableStateFlow(initialState) val state = _state.asStateFlow() var showFabText: Boolean by mutableStateOf(true) - val isSelectable: StateFlow = - multiSelectProvider.state.map { it is SelectionState.Selecting } - .stateIn(coroutineScope, SharingStarted.Eagerly, false) - val setupGuiKeyboardState: StateFlow = combine( setupGuiKeyboard.isInstalled, setupGuiKeyboard.isEnabled, @@ -73,67 +129,319 @@ class KeyMapListViewModel( var showDpadTriggerSetupBottomSheet: Boolean by mutableStateOf(false) + private val keyMapGroupStateFlow = listKeyMaps.keyMapGroup.stateIn( + coroutineScope, + SharingStarted.Eagerly, + KeyMapGroup( + group = null, + subGroups = emptyList(), + keyMaps = State.Loading, + parents = emptyList(), + ), + ) + + private val _importExportState = MutableStateFlow(ImportExportState.Idle) + val importExportState: StateFlow = _importExportState.asStateFlow() + + private val isEditingGroupName = MutableStateFlow(false) + + private val warnings: Flow> = combine( + showAlertsUseCase.isBatteryOptimised, + showAlertsUseCase.accessibilityServiceState, + showAlertsUseCase.hideAlerts, + showAlertsUseCase.isLoggingEnabled, + ) { isBatteryOptimised, serviceState, isHidden, isLoggingEnabled -> + if (isHidden) { + return@combine emptyList() + } + + buildList { + when (serviceState) { + ServiceState.CRASHED -> + add( + HomeWarningListItem( + ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM, + getString(R.string.home_error_accessibility_service_is_crashed), + ), + ) + + ServiceState.DISABLED -> + add( + HomeWarningListItem( + ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM, + getString(R.string.home_error_accessibility_service_is_disabled), + ), + ) + + ServiceState.ENABLED -> {} + } + + if (isBatteryOptimised) { + add( + HomeWarningListItem( + ID_BATTERY_OPTIMISATION_LIST_ITEM, + getString(R.string.home_error_is_battery_optimised), + ), + ) + } // don't show a success message for this + + if (isLoggingEnabled) { + add( + HomeWarningListItem( + ID_LOGGING_ENABLED_LIST_ITEM, + getString(R.string.home_error_logging_enabled), + ), + ) + } + } + } + + /** + * Whether the current group was just created and hasn't been saved with a user defined + * name yet. + */ + private var isNewGroup = false + init { - val keyMapListFlow = combine( - listKeyMaps.keyMapList, + val sortedKeyMapsFlow = combine( + keyMapGroupStateFlow.map { it.keyMaps }.distinctUntilChanged(), sortKeyMaps.observeKeyMapsSorter(), - ) { keyMapList, sorter -> - keyMapList.mapData { list -> list.sortedWith(sorter) } + ) { keyMapsState, sorter -> + keyMapsState.mapData { list -> list.sortedWith(sorter) } }.flowOn(Dispatchers.Default) val listItemContentFlow = combine( - keyMapListFlow, + sortedKeyMapsFlow, listKeyMaps.showDeviceDescriptors, listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, - ) { keyMapListState, showDeviceDescriptors, triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot -> - keyMapListState.mapData { keyMapList -> - keyMapList.map { keyMap -> - listItemCreator.create( - keyMap, - showDeviceDescriptors, - triggerErrorSnapshot, - actionErrorSnapshot, - constraintErrorSnapshot, - ) - } - } - }.flowOn(Dispatchers.Default) + transform = ::buildListItems, + ).flowOn(Dispatchers.Default) // The list item content should be separate from the selection state // because creating the content is an expensive operation and selection should be almost // instantaneous. - coroutineScope.launch(Dispatchers.Default) { + val listItemStateFlow = combine( + listItemContentFlow, + multiSelectProvider.state, + ) { contentListState, selectionState -> + contentListState.mapData { contentList -> + if (selectionState is SelectionState.Selecting) { + contentList.map { item -> + KeyMapListItemModel( + isSelected = selectionState.selectedIds.contains(item.uid), + content = item, + ) + } + } else { + contentList.map { contentListItem -> + KeyMapListItemModel(isSelected = false, contentListItem) + } + } + } + } + + val selectionGroupFamilyStateFlow = listKeyMaps.selectionGroupFamily.stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5000), + GroupFamily(null, emptyList(), emptyList()), + ) + val selectionBreadcrumbs: Flow> = + selectionGroupFamilyStateFlow.map { family -> + family.parents.plus(family.group).filterNotNull() + .map { GroupListItemModel(uid = it.uid, name = it.name, icon = null) } + } + + val selectionGroupListItems: Flow> = + selectionGroupFamilyStateFlow.map { family -> + family.children.map { + GroupListItemModel( + uid = it.uid, + name = it.name, + icon = null, + ) + } + } + + val showThisGroup = combine( + keyMapGroupStateFlow, + selectionGroupFamilyStateFlow, + ) { keyMapGroup, selectionGroup -> keyMapGroup.group?.uid != selectionGroup.group?.uid } + + val selectionAppBarState = combine( + multiSelectProvider.state.filterIsInstance(), + keyMapGroupStateFlow, + selectionGroupListItems, + selectionBreadcrumbs, + showThisGroup, + ) { selectionState, keyMapGroup, groups, selectionBreadcrumbs, showThisGroup -> + buildSelectingAppBarState( + keyMapGroup, + selectionState, + groups, + selectionBreadcrumbs, + showThisGroup, + ) + } + + val groupAppBarState = combine( + keyMapGroupStateFlow, + warnings, + pauseKeyMaps.isPaused, + listKeyMaps.constraintErrorSnapshot, + isEditingGroupName, + transform = ::buildGroupAppBarState, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val appBarStateFlow: Flow = + multiSelectProvider.state.flatMapLatest { selectionState -> + when (selectionState) { + is SelectionState.Selecting -> selectionAppBarState + SelectionState.NotSelecting -> groupAppBarState + } + } + + coroutineScope.launch { combine( - listItemContentFlow, - multiSelectProvider.state, - ) { keymapListState, selectionState -> - Pair(keymapListState, selectionState) - }.collectLatest { (listItemContentList, selectionState) -> - // Stop selecting when there are no key maps - listItemContentList.ifIsData { list -> - if (list.isEmpty()) { - multiSelectProvider.stopSelecting() + listItemStateFlow, + appBarStateFlow, + ) { listState, appBarState -> + Pair(listState, appBarState) + }.collectLatest { (listState, appBarState) -> + listState.ifIsData { list -> + if (list.isNotEmpty()) { + showFabText = false } } - showFabText = listItemContentList.dataOrNull()?.isEmpty() ?: true + _state.value = KeyMapListState(appBarState, listState) + } + } + + coroutineScope.launch { + backupRestore.onAutomaticBackupResult.collectLatest { result -> + onAutomaticBackupResult(result) + } + } + } - _state.value = listItemContentList.mapData { contentList -> - contentList.map { content -> - val isSelected = if (selectionState is SelectionState.Selecting) { - selectionState.selectedIds.contains(content.uid) - } else { - false - } + private fun buildSelectingAppBarState( + keyMapGroup: KeyMapGroup, + selectionState: SelectionState.Selecting, + groupListItems: List, + breadcrumbListItems: List, + showThisGroup: Boolean, + ): KeyMapAppBarState.Selecting { + var selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = null + val keyMaps = keyMapGroup.keyMaps.dataOrNull() ?: emptyList() - KeyMapListItemModel(isSelected, content) + for (keyMap in keyMaps) { + if (keyMap.uid in selectionState.selectedIds) { + if (selectedKeyMapsEnabled == null) { + selectedKeyMapsEnabled = if (keyMap.isEnabled) { + SelectedKeyMapsEnabled.ALL + } else { + SelectedKeyMapsEnabled.NONE + } + } else { + if ((keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.NONE) || + (!keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.ALL) + ) { + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED + break } } } } + + return KeyMapAppBarState.Selecting( + selectionCount = selectionState.selectedIds.size, + selectedKeyMapsEnabled = selectedKeyMapsEnabled ?: SelectedKeyMapsEnabled.NONE, + isAllSelected = selectionState.selectedIds.size == keyMaps.size, + groups = groupListItems, + breadcrumbs = breadcrumbListItems, + showThisGroup = showThisGroup, + ) + } + + private fun buildGroupAppBarState( + keyMapGroup: KeyMapGroup, + warnings: List, + isPaused: Boolean, + constraintErrorSnapshot: ConstraintErrorSnapshot, + isEditingGroupName: Boolean, + ): KeyMapAppBarState { + val subGroupListItems = keyMapGroup.subGroups.map { group -> + buildGroupListItem(group) + } + + val breadcrumbs = keyMapGroup.parents.plus(keyMapGroup.group).filterNotNull().map { group -> + GroupListItemModel( + uid = group.uid, + name = group.name, + icon = null, + ) + } + + if (keyMapGroup.group == null) { + return KeyMapAppBarState.RootGroup( + subGroups = subGroupListItems, + warnings = warnings, + isPaused = isPaused, + ) + } else { + return KeyMapAppBarState.ChildGroup( + groupName = keyMapGroup.group.name, + constraints = listItemCreator.buildConstraintChipList( + keyMapGroup.group.constraintState, + constraintErrorSnapshot, + ), + constraintMode = keyMapGroup.group.constraintState.mode, + parentConstraintCount = keyMapGroup.parents.sumOf { it.constraintState.constraints.size }, + subGroups = subGroupListItems, + breadcrumbs = breadcrumbs, + isEditingGroupName = isEditingGroupName, + isNewGroup = isNewGroup, + ) + } + } + + private fun buildGroupListItem(group: Group): GroupListItemModel { + var icon: ComposeIconInfo? = null + + val constraint = group.constraintState.constraints.firstOrNull() + if (constraint != null) { + icon = constraintUiHelper.getIcon(constraint) + } + + return GroupListItemModel( + uid = group.uid, + name = group.name, + icon = icon, + ) + } + + private fun buildListItems( + keyMapsState: State>, + showDeviceDescriptors: Boolean, + triggerErrorSnapshot: TriggerErrorSnapshot, + actionErrorSnapshot: ActionErrorSnapshot, + constraintErrorSnapshot: ConstraintErrorSnapshot, + ): State> { + return keyMapsState.mapData { list -> + list.map { + listItemCreator.build( + it, + showDeviceDescriptors, + triggerErrorSnapshot, + actionErrorSnapshot, + constraintErrorSnapshot, + ) + } + } } fun onKeyMapCardClick(uid: String) { @@ -145,15 +453,18 @@ class KeyMapListViewModel( } } else { coroutineScope.launch { - navigate("config_key_map", NavDestination.ConfigKeyMap(uid)) + navigate("config_key_map", NavDestination.ConfigKeyMap.Open(uid)) } } } fun onKeyMapCardLongClick(uid: String) { if (multiSelectProvider.state.value is SelectionState.NotSelecting) { - multiSelectProvider.startSelecting() - multiSelectProvider.select(uid) + coroutineScope.launch { + val currentGroupUid = listKeyMaps.keyMapGroup.first().group?.uid + multiSelectProvider.startSelecting() + multiSelectProvider.select(uid) + } } } @@ -169,18 +480,6 @@ class KeyMapListViewModel( } } - fun selectAll() { - coroutineScope.launch { - state.value.apply { - if (this is State.Data) { - multiSelectProvider.select( - *this.data.map { it.uid }.toTypedArray(), - ) - } - } - } - } - fun onFixTriggerError(error: TriggerError) { coroutineScope.launch { when (error) { @@ -200,8 +499,8 @@ class KeyMapListViewModel( TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED, TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> { navigate( "purchase_advanced_trigger", - NavDestination.ConfigKeyMap( - keyMapUid = null, + NavDestination.ConfigKeyMap.New( + groupUid = null, showAdvancedTriggers = true, ), ) @@ -250,4 +549,319 @@ class KeyMapListViewModel( fun onNeverShowSetupDpadClick() { listKeyMaps.neverShowDpadImeSetupError() } + + fun onSelectAllClick() { + state.value.also { state -> + if (state.appBarState is KeyMapAppBarState.Selecting) { + if (state.appBarState.isAllSelected) { + multiSelectProvider.stopSelecting() + } else { + state.listItems.apply { + if (this is State.Data) { + multiSelectProvider.select( + *this.data.map { it.uid }.toTypedArray(), + ) + } + } + } + } + } + } + + fun onEnabledKeyMapsChange(enabled: Boolean) { + val selectionState = multiSelectProvider.state.value + + if (selectionState !is SelectionState.Selecting) return + val selectedIds = selectionState.selectedIds + + if (enabled) { + listKeyMaps.enableKeyMap(*selectedIds.toTypedArray()) + } else { + listKeyMaps.disableKeyMap(*selectedIds.toTypedArray()) + } + } + + fun onDuplicateSelectedKeyMapsClick() { + val selectionState = multiSelectProvider.state.value + + if (selectionState !is SelectionState.Selecting) return + val selectedIds = selectionState.selectedIds + + listKeyMaps.duplicateKeyMap(*selectedIds.toTypedArray()) + } + + fun onDeleteSelectedKeyMapsClick() { + val selectionState = multiSelectProvider.state.value + + if (selectionState !is SelectionState.Selecting) return + val selectedIds = selectionState.selectedIds.toTypedArray() + + listKeyMaps.deleteKeyMap(*selectedIds) + multiSelectProvider.deselect(*selectedIds) + multiSelectProvider.stopSelecting() + } + + fun onExportSelectedKeyMaps() { + val selectionState = multiSelectProvider.state.value + + if (selectionState !is SelectionState.Selecting) return + + coroutineScope.launch { + val selectedIds = selectionState.selectedIds + + listKeyMaps.backupKeyMaps(*selectedIds.toTypedArray()).onSuccess { + _importExportState.value = ImportExportState.FinishedExport(it) + }.onFailure { + _importExportState.value = + ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) + } + } + } + + fun onSelectionGroupClick(groupUid: String?) { + coroutineScope.launch { + listKeyMaps.openSelectionGroup(groupUid) + } + } + + fun onMoveToThisGroupClick() { + val selectionState = multiSelectProvider.state.value + + if (selectionState !is SelectionState.Selecting) return + val selectedIds = selectionState.selectedIds.toTypedArray() + + listKeyMaps.moveKeyMapsToSelectedGroup(*selectedIds) + + multiSelectProvider.deselect(*selectedIds) + multiSelectProvider.stopSelecting() + } + + fun onFixWarningClick(id: String) { + coroutineScope.launch { + when (id) { + ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM -> { + val explanationResponse = + ViewModelHelper.showAccessibilityServiceExplanationDialog( + resourceProvider = this@KeyMapListViewModel, + popupViewModel = this@KeyMapListViewModel, + ) + + if (explanationResponse != DialogResponse.POSITIVE) { + return@launch + } + + if (!showAlertsUseCase.startAccessibilityService()) { + ViewModelHelper.handleCantFindAccessibilitySettings( + resourceProvider = this@KeyMapListViewModel, + popupViewModel = this@KeyMapListViewModel, + ) + } + } + + ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM -> + ViewModelHelper.handleKeyMapperCrashedDialog( + resourceProvider = this@KeyMapListViewModel, + popupViewModel = this@KeyMapListViewModel, + restartService = showAlertsUseCase::restartAccessibilityService, + ignoreCrashed = showAlertsUseCase::acknowledgeCrashed, + ) + + ID_BATTERY_OPTIMISATION_LIST_ITEM -> showAlertsUseCase.disableBatteryOptimisation() + ID_LOGGING_ENABLED_LIST_ITEM -> showAlertsUseCase.disableLogging() + } + } + } + + fun onTogglePausedClick() { + coroutineScope.launch { + if (pauseKeyMaps.isPaused.first()) { + pauseKeyMaps.resume() + } else { + pauseKeyMaps.pause() + } + } + } + + fun onExportClick() { + coroutineScope.launch { + if (_importExportState.value != ImportExportState.Idle) { + return@launch + } + + _importExportState.value = ImportExportState.Exporting + backupRestore.backupEverything().onSuccess { + _importExportState.value = ImportExportState.FinishedExport(it) + }.onFailure { + _importExportState.value = + ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) + } + } + } + + fun onChooseImportFile(uri: String) { + coroutineScope.launch { + backupRestore.getKeyMapCountInBackup(uri).onSuccess { + _importExportState.value = ImportExportState.ConfirmImport(uri, it) + }.onFailure { + _importExportState.value = + ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) + } + } + } + + fun onConfirmImport(restoreType: RestoreType) { + val state = _importExportState.value as? ImportExportState.ConfirmImport + state ?: return + + _importExportState.value = ImportExportState.Importing + + coroutineScope.launch { + backupRestore.restoreKeyMaps(state.fileUri, restoreType).onSuccess { + _importExportState.value = ImportExportState.FinishedImport + }.onFailure { + _importExportState.value = + ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) + } + } + } + + fun setImportExportIdle() { + _importExportState.value = ImportExportState.Idle + } + + /** + * @return whether the back was handled and the activity should not finish. + */ + fun onBackClick(): Boolean { + when { + multiSelectProvider.state.value is SelectionState.Selecting -> { + multiSelectProvider.stopSelecting() + return true + } + + state.value.appBarState is KeyMapAppBarState.ChildGroup -> { + if (!isEditingGroupName.value) { + coroutineScope.launch { + listKeyMaps.popGroup() + } + } + + isNewGroup = false + isEditingGroupName.update { false } + + return true + } + + else -> { + return false + } + } + } + + suspend fun onRenameGroupClick(name: String): Boolean { + return listKeyMaps.renameGroup(name).also { success -> + if (success) { + isNewGroup = false + isEditingGroupName.update { false } + } + } + } + + fun onEditGroupNameClick() { + isNewGroup = false + isEditingGroupName.update { true } + } + + fun onGroupClick(uid: String?) { + coroutineScope.launch { + isNewGroup = false + isEditingGroupName.update { false } + listKeyMaps.openGroup(uid) + } + } + + fun onDeleteGroupClick() { + coroutineScope.launch { + isNewGroup = false + isEditingGroupName.update { false } + listKeyMaps.deleteGroup() + } + } + + fun onNewGroupClick() { + coroutineScope.launch { + // Must come first + isNewGroup = true + + when (val selectionState = multiSelectProvider.state.value) { + is SelectionState.Selecting -> + listKeyMaps.moveKeyMapsToNewGroup(*selectionState.selectedIds.toTypedArray()) + + SelectionState.NotSelecting -> { + listKeyMaps.newGroup() + } + } + + multiSelectProvider.stopSelecting() + isEditingGroupName.update { true } + } + } + + fun onNewGroupConstraintClick() { + coroutineScope.launch { + val constraint = navigate( + "add_group_constraint", + NavDestination.ChooseConstraint, + ) ?: return@launch + + listKeyMaps.addGroupConstraint(constraint) + } + } + + fun onRemoveGroupConstraintClick(uid: String) { + coroutineScope.launch { + listKeyMaps.removeGroupConstraint(uid) + } + } + + fun onGroupConstraintModeChanged(mode: ConstraintMode) { + coroutineScope.launch { + listKeyMaps.setGroupConstraintMode(mode) + } + } + + fun onNewKeyMapClick() { + coroutineScope.launch { + val groupUid = listKeyMaps.keyMapGroup.first().group?.uid + + navigate( + NavigateEvent( + "config_key_map", + NavDestination.ConfigKeyMap.New(groupUid = groupUid), + ), + ) + } + } + + private suspend fun onAutomaticBackupResult(result: Result<*>) { + when (result) { + is Success -> {} + + is Error -> { + val response = showPopup( + "automatic_backup_error", + PopupUi.Dialog( + title = getString(R.string.toast_automatic_backup_failed), + message = result.getFullMessage(this), + positiveButtonText = getString(R.string.pos_ok), + neutralButtonText = getString(R.string.neutral_go_to_settings), + ), + ) ?: return + + if (response == DialogResponse.NEUTRAL) { + navigate("settings", NavDestination.Settings) + } + } + } + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt index d2c19417a1..0d42b9c77c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt @@ -9,8 +9,9 @@ import kotlinx.coroutines.flow.Flow */ interface KeyMapRepository { val keyMapList: Flow>> - val requestBackup: Flow> + fun getAll(): Flow> + fun getByGroup(groupUid: String?): Flow> fun insert(vararg keyMap: KeyMapEntity) fun update(vararg keyMap: KeyMapEntity) suspend fun get(uid: String): KeyMapEntity? @@ -20,4 +21,5 @@ interface KeyMapRepository { fun duplicate(vararg uid: String) fun enableById(vararg uid: String) fun disableById(vararg uid: String) + fun moveToGroup(groupUid: String?, vararg uid: String) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt index 4e8b12b0ee..d1b233cd92 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt @@ -1,47 +1,319 @@ package io.github.sds100.keymapper.mappings.keymaps +import android.database.sqlite.SQLiteConstraintException +import io.github.sds100.keymapper.R import io.github.sds100.keymapper.backup.BackupManager import io.github.sds100.keymapper.backup.BackupManagerImpl import io.github.sds100.keymapper.backup.BackupUtils +import io.github.sds100.keymapper.constraints.Constraint +import io.github.sds100.keymapper.constraints.ConstraintEntityMapper +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.constraints.ConstraintModeEntityMapper +import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository +import io.github.sds100.keymapper.data.repositories.GroupRepository +import io.github.sds100.keymapper.data.repositories.RepositoryUtils +import io.github.sds100.keymapper.groups.Group +import io.github.sds100.keymapper.groups.GroupEntityMapper +import io.github.sds100.keymapper.groups.GroupFamily import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.dataOrNull +import io.github.sds100.keymapper.util.ui.ResourceProvider import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import java.util.LinkedList /** * Created by sds100 on 16/04/2021. */ +@OptIn(ExperimentalCoroutinesApi::class) class ListKeyMapsUseCaseImpl( private val keyMapRepository: KeyMapRepository, + private val groupRepository: GroupRepository, private val floatingButtonRepository: FloatingButtonRepository, private val fileAdapter: FileAdapter, private val backupManager: BackupManager, + private val resourceProvider: ResourceProvider, displayKeyMapUseCase: DisplayKeyMapUseCase, ) : ListKeyMapsUseCase, DisplayKeyMapUseCase by displayKeyMapUseCase { + private val keyMapListGroupUid = MutableStateFlow(null) + private val selectionGroupUid = MutableStateFlow(null) - override val keyMapList: Flow>> = channelFlow { + private fun setCurrentGroup(groupUid: String?) { + keyMapListGroupUid.update { groupUid } + selectionGroupUid.update { groupUid } + } + + private suspend fun getGroupFamily(groupUid: String?): Flow { + // If the current group is the root then just get the subgroups. + if (groupUid == null) { + return groupRepository.getGroupsByParent(null).map { childrenEntities -> + val children = childrenEntities + .map(GroupEntityMapper::fromEntity) + .sortedByDescending { it.lastOpenedDate } + GroupFamily(group = null, children = children, parents = emptyList()) + } + } else { + val parents = getParentsRecursively(groupUid) + + return groupRepository.getGroupWithChildren(groupUid).map { groupWithChildren -> + val group = GroupEntityMapper.fromEntity(groupWithChildren.group) + val children = groupWithChildren.children.map(GroupEntityMapper::fromEntity) + + GroupFamily(group, children = children, parents = parents) + } + } + } + + override val keyMapGroup: Flow = channelFlow { + keyMapListGroupUid + .flatMapLatest(::getGroupFamily) + .map { groupFamily -> + val parentGroups = getParentsRecursively(groupFamily.group?.uid) + + KeyMapGroup( + group = groupFamily.group, + subGroups = groupFamily.children, + keyMaps = State.Loading, + parents = parentGroups, + ) + }.onEach { send(it) } + .flatMapLatest { keyMapGroup -> + getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } + }.collect { + send(it) + } + } + + override val selectionGroupFamily: Flow = + selectionGroupUid.flatMapLatest(::getGroupFamily) + + override suspend fun openSelectionGroup(uid: String?) { + if (uid == null) { + // If null then open the root group. + selectionGroupUid.update { null } + } else { + // Check if the group exists. + val group = groupRepository.getGroup(uid) ?: return + selectionGroupUid.update { group.uid } + } + } + + private suspend fun getParentsRecursively(groupUid: String?): List { + val list = LinkedList() + var count = 0 + + if (groupUid == null) { + return emptyList() + } + + var currentGroup: String? = groupRepository.getGroup(groupUid)?.parentUid + + while (count < 1000) { + if (currentGroup == null) { + break + } + + val group = groupRepository.getGroup(currentGroup) ?: break + list.addFirst(GroupEntityMapper.fromEntity(group)) + currentGroup = group.parentUid + + count++ + } + + return list + } + + override fun getGroups(parentUid: String?): Flow> { + return groupRepository.getGroupsByParent(parentUid) + .map { list -> list.map(GroupEntityMapper::fromEntity) } + } + + override suspend fun newGroup() { + val newGroup = createNewGroup() + setCurrentGroup(newGroup.uid) + } + + override suspend fun moveKeyMapsToNewGroup(vararg keyMapUids: String) { + val newGroup = createNewGroup() + moveKeyMapsToGroup(newGroup.uid, *keyMapUids) + setCurrentGroup(newGroup.uid) + } + + private suspend fun createNewGroup(): GroupEntity { + val defaultName = resourceProvider.getString(R.string.default_group_name) + var group = GroupEntity( + parentUid = keyMapListGroupUid.value, + name = defaultName, + lastOpenedDate = System.currentTimeMillis(), + ) + + group = ensureUniqueName(group) + groupRepository.insert(group) + return group + } + + override suspend fun deleteGroup() { + keyMapListGroupUid.value?.also { groupUid -> + val group = groupRepository.getGroup(groupUid) ?: return + + setCurrentGroup(group.parentUid) + + groupRepository.delete(groupUid) + } + } + + override suspend fun renameGroup(name: String): Boolean { + if (name.isBlank()) { + return true + } + + keyMapListGroupUid.value?.also { groupUid -> + var entity = groupRepository.getGroup(groupUid) ?: return true + + entity = entity.copy(name = name.trim()) + + val siblings = groupRepository.getGroupsByParent(entity.parentUid).first() + + if (siblings.any { it.uid != groupUid && it.name == entity.name }) { + return false + } + + groupRepository.update(entity) + } + + return true + } + + private suspend fun ensureUniqueName(group: GroupEntity): GroupEntity { + val siblings = groupRepository.getGroupsByParent(group.parentUid).first() + + return RepositoryUtils.saveUniqueName( + entity = group, + saveBlock = { renamedGroup -> + if (siblings.any { sibling -> sibling.uid != group.uid && sibling.name == renamedGroup.name }) { + throw IllegalStateException("Non unique group name") + } + }, + renameBlock = { entity, suffix -> + entity.copy(name = "${entity.name} $suffix") + }, + ) + } + + override suspend fun openGroup(uid: String?) { + if (uid == null) { + // If null then open the root group. + setCurrentGroup(null) + } else { + // Check if the group exists. + val group = groupRepository.getGroup(uid) ?: return + setCurrentGroup(group.uid) + groupRepository.setLastOpenedDate(group.uid, System.currentTimeMillis()) + } + } + + override suspend fun popGroup() { + val currentGroupUid = keyMapListGroupUid.value ?: return + val currentGroup = groupRepository.getGroup(currentGroupUid) + + // If stuck in a non existent group, or the parent is null then pop to the root. + if (currentGroup?.parentUid == null) { + setCurrentGroup(null) + } else { + // Check if the group exists. + val group = groupRepository.getGroup(currentGroup.parentUid) ?: return + setCurrentGroup(group.uid) + } + } + + override suspend fun addGroupConstraint(constraint: Constraint) { + keyMapListGroupUid.value?.also { groupUid -> + val constraintEntity = ConstraintEntityMapper.toEntity(constraint) + var groupEntity = groupRepository.getGroup(groupUid) ?: return + + groupEntity = groupEntity.copy( + constraintList = groupEntity.constraintList.plus(constraintEntity), + ) + + try { + groupRepository.update(groupEntity) + } catch (_: SQLiteConstraintException) { + return + } + } + } + + override suspend fun setGroupConstraintMode(mode: ConstraintMode) { + keyMapListGroupUid.value?.also { groupUid -> + val group = groupRepository.getGroup(groupUid) ?: return + + val groupEntity = group.copy(constraintMode = ConstraintModeEntityMapper.toEntity(mode)) + + try { + groupRepository.update(groupEntity) + } catch (_: SQLiteConstraintException) { + return + } + } + } + + override suspend fun removeGroupConstraint(constraintUid: String) { + keyMapListGroupUid.value?.also { groupUid -> + val groupEntity = groupRepository.getGroup(groupUid) ?: return + var group = GroupEntityMapper.fromEntity(groupEntity) + + val constraints = group.constraintState.constraints + .filterNot { it.uid == constraintUid } + .toSet() + + group = + group.copy(constraintState = group.constraintState.copy(constraints = constraints)) + + try { + groupRepository.update(GroupEntityMapper.toEntity(group)) + } catch (_: SQLiteConstraintException) { + return + } + } + } + + override fun moveKeyMapsToGroup(groupUid: String?, vararg keyMapUids: String) { + keyMapRepository.moveToGroup(groupUid, *keyMapUids) + } + + override fun moveKeyMapsToSelectedGroup(vararg keyMapUids: String) { + keyMapRepository.moveToGroup(selectionGroupUid.value, *keyMapUids) + } + + private fun getKeyMapsByGroup(groupUid: String?): Flow>> = channelFlow { send(State.Loading) combine( - keyMapRepository.keyMapList, + keyMapRepository.getByGroup(groupUid), floatingButtonRepository.buttonsList, - ) { keyMapListState, buttonListState -> - Pair(keyMapListState, buttonListState) - }.collectLatest { (keyMapListState, buttonListState) -> - if (keyMapListState is State.Loading || buttonListState is State.Loading) { + ) { keyMapList, buttonListState -> + Pair(keyMapList, buttonListState) + }.collectLatest { (keyMapList, buttonListState) -> + if (buttonListState is State.Loading) { send(State.Loading) } - val keyMapList = keyMapListState.dataOrNull() ?: return@collectLatest val buttonList = buttonListState.dataOrNull() ?: return@collectLatest val keyMaps = withContext(Dispatchers.Default) { @@ -86,7 +358,23 @@ class ListKeyMapsUseCaseImpl( } interface ListKeyMapsUseCase : DisplayKeyMapUseCase { - val keyMapList: Flow>> + val keyMapGroup: Flow + + suspend fun newGroup() + suspend fun openGroup(uid: String?) + suspend fun popGroup() + suspend fun deleteGroup() + suspend fun renameGroup(name: String): Boolean + suspend fun addGroupConstraint(constraint: Constraint) + suspend fun removeGroupConstraint(constraintUid: String) + suspend fun setGroupConstraintMode(mode: ConstraintMode) + fun getGroups(parentUid: String?): Flow> + + val selectionGroupFamily: Flow + suspend fun openSelectionGroup(uid: String?) + fun moveKeyMapsToGroup(groupUid: String?, vararg keyMapUids: String) + fun moveKeyMapsToSelectedGroup(vararg keyMapUids: String) + suspend fun moveKeyMapsToNewGroup(vararg keyMapUids: String) fun deleteKeyMap(vararg uid: String) fun enableKeyMap(vararg uid: String) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapModel.kt new file mode 100644 index 0000000000..6242e555bd --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapModel.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.mappings.keymaps.detection + +import io.github.sds100.keymapper.constraints.ConstraintState +import io.github.sds100.keymapper.mappings.keymaps.KeyMap + +data class DetectKeyMapModel( + val keyMap: KeyMap, + val groupConstraintStates: List = emptyList(), +) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt index 9f9fd9d87a..1ae73e4351 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt @@ -4,10 +4,14 @@ import android.accessibilityservice.AccessibilityService import android.os.SystemClock import android.view.KeyEvent import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository +import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.groups.Group +import io.github.sds100.keymapper.groups.GroupEntityMapper import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapEntityMapper import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository @@ -42,6 +46,7 @@ import timber.log.Timber class DetectKeyMapsUseCaseImpl( private val keyMapRepository: KeyMapRepository, private val floatingButtonRepository: FloatingButtonRepository, + private val groupRepository: GroupRepository, private val preferenceRepository: PreferenceRepository, private val suAdapter: SuAdapter, private val displayAdapter: DisplayAdapter, @@ -55,32 +60,77 @@ class DetectKeyMapsUseCaseImpl( private val vibrator: VibratorAdapter, ) : DetectKeyMapsUseCase { - override val allKeyMapList: Flow> = combine( + companion object { + fun processKeyMapsAndGroups( + keyMaps: List, + groups: List, + ): List = buildList { + val groupMap = groups.associateBy { it.uid } + + keyMapLoop@ for (keyMap in keyMaps) { + var depth = 0 + var groupUid: String? = keyMap.groupUid + val constraintStates = mutableListOf() + + while (depth < 100) { + if (groupUid == null) { + add( + DetectKeyMapModel( + keyMap = keyMap, + groupConstraintStates = constraintStates, + ), + ) + break + } + + if (!groupMap.containsKey(groupUid)) { + continue@keyMapLoop + } + + val group = groupMap[groupUid]!! + groupUid = group.parentUid + + if (group.constraintState.constraints.isNotEmpty()) { + constraintStates.add(group.constraintState) + } + + depth++ + } + } + } + } + + override val allKeyMapList: Flow> = combine( keyMapRepository.keyMapList, floatingButtonRepository.buttonsList, - ) { keyMapListState, buttonListState -> + groupRepository.groups, + ) { keyMapListState, buttonListState, groupEntities -> if (keyMapListState is State.Loading || buttonListState is State.Loading) { return@combine emptyList() } - val keyMapList = keyMapListState.dataOrNull() ?: return@combine emptyList() - val buttonList = buttonListState.dataOrNull() ?: return@combine emptyList() + val keyMapEntityList = keyMapListState.dataOrNull() ?: return@combine emptyList() + val buttonEntityList = buttonListState.dataOrNull() ?: return@combine emptyList() - keyMapList.map { keyMap -> - KeyMapEntityMapper.fromEntity(keyMap, buttonList) + val keyMapList = keyMapEntityList.map { keyMap -> + KeyMapEntityMapper.fromEntity(keyMap, buttonEntityList) } + + val groupList = groupEntities.map { GroupEntityMapper.fromEntity(it) } + + processKeyMapsAndGroups(keyMapList, groupList) }.flowOn(Dispatchers.Default) override val requestFingerprintGestureDetection: Flow = - allKeyMapList.map { keyMaps -> - keyMaps.any { keyMap -> - keyMap.isEnabled && keyMap.trigger.keys.any { it is FingerprintTriggerKey } + allKeyMapList.map { models -> + models.any { model -> + model.keyMap.isEnabled && model.keyMap.trigger.keys.any { it is FingerprintTriggerKey } } } override val keyMapsToTriggerFromOtherApps: Flow> = allKeyMapList.map { keyMapList -> - keyMapList.filter { it.trigger.triggerFromOtherApps } + keyMapList.filter { it.keyMap.trigger.triggerFromOtherApps }.map { it.keyMap } }.flowOn(Dispatchers.Default) override val detectScreenOffTriggers: Flow = @@ -88,7 +138,7 @@ class DetectKeyMapsUseCaseImpl( allKeyMapList, suAdapter.isGranted, ) { keyMapList, isRootPermissionGranted -> - keyMapList.any { it.trigger.screenOffTrigger } && isRootPermissionGranted + keyMapList.any { it.keyMap.trigger.screenOffTrigger } && isRootPermissionGranted }.flowOn(Dispatchers.Default) override val defaultLongPressDelay: Flow = @@ -184,7 +234,7 @@ class DetectKeyMapsUseCaseImpl( } interface DetectKeyMapsUseCase { - val allKeyMapList: Flow> + val allKeyMapList: Flow> val requestFingerprintGestureDetection: Flow val keyMapsToTriggerFromOtherApps: Flow> val detectScreenOffTriggers: Flow diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index f65d21ee6d..7116cf1550 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -456,6 +456,24 @@ abstract class BaseConfigTriggerViewModel( // need to be dismissed before it is added. config.addKeyCodeTriggerKey(key.keyCode, key.device, key.detectionSource) + if (key.keyCode >= InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET || key.keyCode < 0) { + if (onboarding.shownKeyCodeToScanCodeTriggerExplanation) { + return + } + + val dialog = PopupUi.Dialog( + title = getString(R.string.dialog_title_keycode_to_scancode_trigger_explanation), + message = getString(R.string.dialog_message_keycode_to_scancode_trigger_explanation), + positiveButtonText = getString(R.string.pos_understood), + ) + + val response = showPopup("keycode_to_scancode_message", dialog) + + if (response == DialogResponse.POSITIVE) { + onboarding.shownKeyCodeToScanCodeTriggerExplanation = true + } + } + if (key.keyCode == KeyEvent.KEYCODE_CAPS_LOCK) { val dialog = PopupUi.Ok( message = getString(R.string.dialog_message_enable_physical_keyboard_caps_lock_a_keyboard_layout), diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt index 314560b7ec..c56c550109 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt @@ -56,6 +56,8 @@ fun TriggerScreen(modifier: Modifier = Modifier, viewModel: ConfigTriggerViewMod val setupGuiKeyboardState by viewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() val recordTriggerState by viewModel.recordTriggerState.collectAsStateWithLifecycle() + HandleAssistantTriggerSetupBottomSheet(viewModel = viewModel) + if (viewModel.showAdvancedTriggersBottomSheet) { AdvancedTriggersBottomSheet( modifier = Modifier.systemBarsPadding(), @@ -209,7 +211,7 @@ private fun TriggerScreenVertical( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = configState.shortcuts, onClick = onClickShortcut, @@ -313,7 +315,7 @@ private fun TriggerScreenHorizontal( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = configState.shortcuts, onClick = onClickShortcut, @@ -456,7 +458,9 @@ private fun TriggerList( Spacer(Modifier.height(8.dp)) ShortcutRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), shortcuts = shortcuts, onClick = { onClickShortcut(it) }, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt index bc12b49c18..73b90c0118 100644 --- a/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt @@ -65,6 +65,10 @@ class OnboardingUseCaseImpl( Keys.shownSequenceTriggerExplanation, false, ) + override var shownKeyCodeToScanCodeTriggerExplanation by PrefDelegate( + Keys.shownKeyCodeToScanCodeTriggerExplanation, + false, + ) override val showWhatsNew = get(Keys.lastInstalledVersionCodeHomeScreen) .map { (it ?: -1) < Constants.VERSION_CODE } @@ -167,6 +171,7 @@ interface OnboardingUseCase { var shownParallelTriggerOrderExplanation: Boolean var shownSequenceTriggerExplanation: Boolean + var shownKeyCodeToScanCodeTriggerExplanation: Boolean val showFloatingButtonFeatureNotification: Flow fun showedFloatingButtonFeatureNotification() diff --git a/app/src/main/java/io/github/sds100/keymapper/settings/Android11BugWorkaroundSettingsFragment.kt b/app/src/main/java/io/github/sds100/keymapper/settings/Android11BugWorkaroundSettingsFragment.kt index 94f73f78ad..890cf8e1f3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/settings/Android11BugWorkaroundSettingsFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/settings/Android11BugWorkaroundSettingsFragment.kt @@ -8,12 +8,12 @@ import androidx.preference.SwitchPreference import androidx.preference.isEmpty import io.github.sds100.keymapper.R import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.home.ChooseAppStoreModel import io.github.sds100.keymapper.system.leanback.LeanbackUtils import io.github.sds100.keymapper.system.url.UrlUtils import io.github.sds100.keymapper.util.drawable import io.github.sds100.keymapper.util.launchRepeatOnLifecycle import io.github.sds100.keymapper.util.str +import io.github.sds100.keymapper.util.ui.ChooseAppStoreModel import io.github.sds100.keymapper.util.ui.PopupUi import io.github.sds100.keymapper.util.ui.showPopup import io.github.sds100.keymapper.util.viewLifecycleScope diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt index 940973efb3..156a9a0061 100644 --- a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt @@ -91,24 +91,24 @@ class KeyMapConstraintsComparator( is Constraint.AppPlayingMedia -> displayConstraints.getAppName(constraint.packageName) is Constraint.BtDeviceConnected -> Success(constraint.deviceName) is Constraint.BtDeviceDisconnected -> Success(constraint.deviceName) - Constraint.Charging -> Success("") - Constraint.DeviceIsLocked -> Success("") - Constraint.DeviceIsUnlocked -> Success("") - Constraint.Discharging -> Success("") + is Constraint.Charging -> Success("") + is Constraint.DeviceIsLocked -> Success("") + is Constraint.DeviceIsUnlocked -> Success("") + is Constraint.Discharging -> Success("") is Constraint.FlashlightOff -> Success(constraint.lens.toString()) is Constraint.FlashlightOn -> Success(constraint.lens.toString()) is Constraint.ImeChosen -> Success(constraint.imeLabel) is Constraint.ImeNotChosen -> Success(constraint.imeLabel) - Constraint.InPhoneCall -> Success("") - Constraint.MediaPlaying -> Success("") - Constraint.NoMediaPlaying -> Success("") - Constraint.NotInPhoneCall -> Success("") + is Constraint.InPhoneCall -> Success("") + is Constraint.MediaPlaying -> Success("") + is Constraint.NoMediaPlaying -> Success("") + is Constraint.NotInPhoneCall -> Success("") is Constraint.OrientationCustom -> Success(constraint.orientation.toString()) - Constraint.OrientationLandscape -> Success("") - Constraint.OrientationPortrait -> Success("") - Constraint.PhoneRinging -> Success("") - Constraint.ScreenOff -> Success("") - Constraint.ScreenOn -> Success("") + is Constraint.OrientationLandscape -> Success("") + is Constraint.OrientationPortrait -> Success("") + is Constraint.PhoneRinging -> Success("") + is Constraint.ScreenOff -> Success("") + is Constraint.ScreenOn -> Success("") is Constraint.WifiConnected -> if (constraint.ssid == null) { Success("") } else { @@ -121,10 +121,10 @@ class KeyMapConstraintsComparator( Success(constraint.ssid) } - Constraint.WifiOff -> Success("") - Constraint.WifiOn -> Success("") - Constraint.LockScreenNotShowing -> Success("") - Constraint.LockScreenShowing -> Success("") + is Constraint.WifiOff -> Success("") + is Constraint.WifiOn -> Success("") + is Constraint.LockScreenNotShowing -> Success("") + is Constraint.LockScreenShowing -> Success("") } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 1c6981df4b..6da22df6af 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt @@ -14,7 +14,7 @@ import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.FingerprintGestureType import io.github.sds100.keymapper.mappings.FingerprintGesturesSupportedUseCase -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectScreenOffKeyEventsController import io.github.sds100.keymapper.mappings.keymaps.detection.DpadMotionEventTracker @@ -24,6 +24,7 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyEventDetectionSour import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsController import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCase import io.github.sds100.keymapper.system.devices.DevicesAdapter +import io.github.sds100.keymapper.system.inputevents.InputEventUtils import io.github.sds100.keymapper.system.inputevents.MyKeyEvent import io.github.sds100.keymapper.system.inputevents.MyMotionEvent import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter @@ -67,7 +68,7 @@ abstract class BaseAccessibilityServiceController( private val detectKeyMapsUseCase: DetectKeyMapsUseCase, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, rerouteKeyEventsUseCase: RerouteKeyEventsUseCase, - private val pauseMappingsUseCase: PauseMappingsUseCase, + private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, private val devicesAdapter: DevicesAdapter, private val suAdapter: SuAdapter, private val inputMethodAdapter: InputMethodAdapter, @@ -106,7 +107,7 @@ abstract class BaseAccessibilityServiceController( private val recordDpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() - val isPaused: StateFlow = pauseMappingsUseCase.isPaused + val isPaused: StateFlow = pauseKeyMapsUseCase.isPaused .stateIn(coroutineScope, SharingStarted.Eagerly, false) private val screenOffTriggersEnabled: StateFlow = @@ -204,7 +205,7 @@ abstract class BaseAccessibilityServiceController( }.launchIn(coroutineScope) } - pauseMappingsUseCase.isPaused.distinctUntilChanged().onEach { + pauseKeyMapsUseCase.isPaused.distinctUntilChanged().onEach { keyMapController.reset() triggerKeyMapFromOtherAppsController.reset() }.launchIn(coroutineScope) @@ -234,7 +235,7 @@ abstract class BaseAccessibilityServiceController( }.launchIn(coroutineScope) combine( - pauseMappingsUseCase.isPaused, + pauseKeyMapsUseCase.isPaused, detectKeyMapsUseCase.allKeyMapList, ) { isPaused, keyMaps -> val enableAccessibilityVolumeStream: Boolean @@ -242,8 +243,8 @@ abstract class BaseAccessibilityServiceController( if (isPaused) { enableAccessibilityVolumeStream = false } else { - enableAccessibilityVolumeStream = keyMaps.any { mapping -> - mapping.isEnabled && mapping.actionList.any { it.data is ActionData.Sound } + enableAccessibilityVolumeStream = keyMaps.any { model -> + model.keyMap.isEnabled && model.keyMap.actionList.any { it.data is ActionData.Sound } } } @@ -296,6 +297,29 @@ abstract class BaseAccessibilityServiceController( open fun onConfigurationChanged(newConfig: Configuration) { } + /** + * Returns an MyKeyEvent which is either the same or more unique + */ + private fun getUniqueEvent(event: MyKeyEvent): MyKeyEvent { + // Guard to ignore processing when not applicable + if (event.keyCode != KeyEvent.KEYCODE_UNKNOWN) return event + + // Don't offset negative values + val scanCodeOffset: Int = if (event.scanCode >= 0) { + InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET + } else { + 0 + } + + val eventProxy = event.copy( + // Fallback to scanCode when keyCode is unknown as it's typically more unique + // Add offset to go past possible keyCode values + keyCode = event.scanCode + scanCodeOffset, + ) + + return eventProxy + } + fun onKeyEvent( event: MyKeyEvent, detectionSource: KeyEventDetectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, @@ -305,11 +329,14 @@ abstract class BaseAccessibilityServiceController( if (recordingTrigger) { if (event.action == KeyEvent.ACTION_DOWN) { Timber.d("Recorded key ${KeyEvent.keyCodeToString(event.keyCode)}, $detailedLogInfo") + + val uniqueEvent: MyKeyEvent = getUniqueEvent(event) + coroutineScope.launch { outputEvents.emit( ServiceEvent.RecordedTriggerKey( - event.keyCode, - event.device, + uniqueEvent.keyCode, + uniqueEvent.device, detectionSource, ), ) @@ -327,16 +354,17 @@ abstract class BaseAccessibilityServiceController( } else { try { var consume: Boolean + val uniqueEvent: MyKeyEvent = getUniqueEvent(event) - consume = keyMapController.onKeyEvent(event) + consume = keyMapController.onKeyEvent(uniqueEvent) if (!consume) { - consume = rerouteKeyEventsController.onKeyEvent(event) + consume = rerouteKeyEventsController.onKeyEvent(uniqueEvent) } - when (event.action) { - KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(event.keyCode)} - consumed: $consume, $detailedLogInfo") - KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(event.keyCode)} - consumed: $consume, $detailedLogInfo") + when (uniqueEvent.action) { + KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(uniqueEvent.keyCode)} - consumed: $consume, $detailedLogInfo") + KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(uniqueEvent.keyCode)} - consumed: $consume, $detailedLogInfo") } return consume diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index dbf4804dac..d2a41d3712 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -72,7 +72,7 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { val maxFlashStrength = getCharacteristicForLens( lens, CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, - ) + ) ?: 1 val defaultFlashStrength = getCharacteristicForLens( lens, @@ -80,9 +80,9 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { ) return CameraFlashInfo( - supportsVariableStrength = true, + supportsVariableStrength = maxFlashStrength > 1, defaultStrength = defaultFlashStrength ?: 1, - maxStrength = maxFlashStrength ?: 1, + maxStrength = maxFlashStrength, ) } else { return CameraFlashInfo( @@ -152,6 +152,11 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.TIRAMISU) } + // If the flash is disabled and it should be decreased then do nothing. + if (percent < 0 && isFlashEnabledMap.value[lens] == false) { + return Success(Unit) + } + try { val cameraId = getFlashlightCameraIdForLens(lens) @@ -177,9 +182,14 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { val newStrength = (currentStrength + (percent * maxStrength)) .toInt() - .coerceIn(1, maxStrength) + .coerceAtMost(maxStrength) - cameraManager.turnOnTorchWithStrengthLevel(cameraId, newStrength) + // If we want to go below the current strength then turn off the flashlight. + if (newStrength < 1) { + cameraManager.setTorchMode(cameraId, false) + } else { + cameraManager.turnOnTorchWithStrengthLevel(cameraId, newStrength) + } } return Success(Unit) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 14e0e62377..1a9c2bfa98 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -57,7 +57,10 @@ class AndroidDisplayAdapter( } } - Intent.ACTION_SCREEN_OFF -> isScreenOn.update { false } + Intent.ACTION_SCREEN_OFF -> { + isAmbientDisplayEnabled.update { isAodEnabled() } + isScreenOn.update { false } + } } } } @@ -74,6 +77,9 @@ class AndroidDisplayAdapter( override val size: SizeKM get() = ctx.getRealDisplaySize() + override val isAmbientDisplayEnabled: MutableStateFlow = + MutableStateFlow(isAodEnabled()) + init { displayManager.registerDisplayListener( object : DisplayManager.DisplayListener { @@ -232,4 +238,8 @@ class AndroidDisplayAdapter( else -> throw Exception("Don't know how to convert $sdkRotation to Orientation") } + + private fun isAodEnabled(): Boolean { + return SettingsUtils.getSecureSetting(ctx, "doze_always_on") == 1 + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt index 25947af603..765797348f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt @@ -9,6 +9,7 @@ interface DisplayAdapter { val orientation: Flow val cachedOrientation: Orientation val size: SizeKM + val isAmbientDisplayEnabled: Flow fun isAutoRotateEnabled(): Boolean fun enableAutoRotate(): Result<*> diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt index 385250d437..d5b336eabc 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt @@ -686,12 +686,21 @@ object InputEventUtils { KeyEvent.KEYCODE_FUNCTION, ) + /** + * Used for keyCode to scanCode fallback to go past possible keyCode values + */ + val KEYCODE_TO_SCANCODE_OFFSET: Int = 1000 + /** * Create a text representation of a key event. E.g if the control key was pressed, * "Ctrl" will be returned */ fun keyCodeToString(keyCode: Int): String = NON_CHARACTER_KEY_LABELS[keyCode].let { - it ?: "unknown keycode $keyCode" + if (keyCode >= KEYCODE_TO_SCANCODE_OFFSET || keyCode < 0) { + "scancode $keyCode" + } else { + it ?: "unknown keycode $keyCode" + } } fun isModifierKey(keyCode: Int): Boolean = keyCode in MODIFIER_KEYCODES diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/AutoSwitchImeController.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/AutoSwitchImeController.kt index d35290e69a..63f41376a0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/AutoSwitchImeController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/AutoSwitchImeController.kt @@ -4,7 +4,7 @@ import io.github.sds100.keymapper.R import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.system.accessibility.ServiceAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.popup.PopupMessageAdapter @@ -28,7 +28,7 @@ class AutoSwitchImeController( private val coroutineScope: CoroutineScope, private val preferenceRepository: PreferenceRepository, private val inputMethodAdapter: InputMethodAdapter, - private val pauseMappingsUseCase: PauseMappingsUseCase, + private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, private val devicesAdapter: DevicesAdapter, private val popupMessageAdapter: PopupMessageAdapter, private val resourceProvider: ResourceProvider, @@ -55,7 +55,7 @@ class AutoSwitchImeController( private var showToast: Boolean = PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME init { - pauseMappingsUseCase.isPaused.onEach { isPaused -> + pauseKeyMapsUseCase.isPaused.onEach { isPaused -> if (!toggleKeyboardOnToggleKeymaps) return@onEach diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt index 9d1c5eac8d..b3168fd813 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt @@ -6,7 +6,7 @@ import androidx.core.app.NotificationManagerCompat import io.github.sds100.keymapper.BaseMainActivity import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase import io.github.sds100.keymapper.system.accessibility.ControlAccessibilityServiceUseCase import io.github.sds100.keymapper.system.accessibility.ServiceState @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch class NotificationController( private val coroutineScope: CoroutineScope, private val manageNotifications: ManageNotificationsUseCase, - private val pauseMappings: PauseMappingsUseCase, + private val pauseMappings: PauseKeyMapsUseCase, private val showImePicker: ShowInputMethodPickerUseCase, private val controlAccessibilityService: ControlAccessibilityServiceUseCase, private val toggleCompatibleIme: ToggleCompatibleImeUseCase, diff --git a/app/src/main/java/io/github/sds100/keymapper/system/tiles/ToggleMappingsTile.kt b/app/src/main/java/io/github/sds100/keymapper/system/tiles/ToggleMappingsTile.kt index 5afb0abd08..e838c840e5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/tiles/ToggleMappingsTile.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/tiles/ToggleMappingsTile.kt @@ -28,7 +28,7 @@ class ToggleMappingsTile : LifecycleOwner { private val serviceAdapter by lazy { ServiceLocator.accessibilityServiceAdapter(this) } - private val useCase by lazy { UseCases.pauseMappings(this) } + private val useCase by lazy { UseCases.pauseKeyMaps(this) } private lateinit var lifecycleRegistry: LifecycleRegistry diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index e42bb0b5a6..f24c2b27b3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -72,6 +72,7 @@ object Inject { ServiceLocator.networkAdapter(ctx), ServiceLocator.inputMethodAdapter(ctx), ServiceLocator.settingsRepository(ctx), + ServiceLocator.cameraAdapter(ctx), ), ServiceLocator.resourceProvider(ctx), ) @@ -138,9 +139,11 @@ object Inject { UseCases.configKeyMap(ctx), ListKeyMapsUseCaseImpl( ServiceLocator.roomKeyMapRepository(ctx), + ServiceLocator.groupRepository(ctx), ServiceLocator.floatingButtonRepository(ctx), ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), + ServiceLocator.resourceProvider(ctx), UseCases.displayKeyMap(ctx), ), UseCases.createKeymapShortcut(ctx), @@ -150,12 +153,14 @@ object Inject { fun homeViewModel(ctx: Context): HomeViewModel.Factory = HomeViewModel.Factory( ListKeyMapsUseCaseImpl( ServiceLocator.roomKeyMapRepository(ctx), + ServiceLocator.groupRepository(ctx), ServiceLocator.floatingButtonRepository(ctx), ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), + ServiceLocator.resourceProvider(ctx), UseCases.displayKeyMap(ctx), ), - UseCases.pauseMappings(ctx), + UseCases.pauseKeyMaps(ctx), BackupRestoreMappingsUseCaseImpl( ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), @@ -164,7 +169,7 @@ object Inject { ServiceLocator.settingsRepository(ctx), ServiceLocator.permissionAdapter(ctx), ServiceLocator.accessibilityServiceAdapter(ctx), - UseCases.pauseMappings(ctx), + UseCases.pauseKeyMaps(ctx), ), UseCases.onboarding(ctx), ServiceLocator.resourceProvider(ctx), @@ -213,7 +218,7 @@ object Inject { keyEventRelayService = keyEventRelayService, ), fingerprintGesturesSupportedUseCase = UseCases.fingerprintGesturesSupported(service), - pauseMappingsUseCase = UseCases.pauseMappings(service), + pauseKeyMapsUseCase = UseCases.pauseKeyMaps(service), devicesAdapter = ServiceLocator.devicesAdapter(service), suAdapter = ServiceLocator.suAdapter(service), rerouteKeyEventsUseCase = UseCases.rerouteKeyEvents( diff --git a/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt index ceaeca6af7..0a3335c89d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt @@ -43,3 +43,7 @@ fun String.getWordBoundaries(@IntRange(from = 0L) cursorPosition: Int): Pair { override val id: String = ID_ABOUT } - data class ConfigKeyMap(val keyMapUid: String?, val showAdvancedTriggers: Boolean = false) : NavDestination() { + sealed class ConfigKeyMap : NavDestination() { override val id: String = ID_CONFIG_KEY_MAP + abstract val showAdvancedTriggers: Boolean + + data class Open(val keyMapUid: String, override val showAdvancedTriggers: Boolean = false) : ConfigKeyMap() + + data class New(val groupUid: String?, override val showAdvancedTriggers: Boolean = false) : ConfigKeyMap() } data object ChooseFloatingLayout : NavDestination() { diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt index 7c1754adca..eaf76d4b9d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt @@ -206,11 +206,19 @@ fun NavigationViewModel.setupNavigation(fragment: Fragment) { NavDestination.About -> NavAppDirections.actionGlobalAboutFragment() NavDestination.Settings -> NavAppDirections.toSettingsFragment() - is NavDestination.ConfigKeyMap -> - NavAppDirections.actionToConfigKeymap( - destination.keyMapUid, - showAdvancedTriggers = destination.showAdvancedTriggers, - ) + is NavDestination.ConfigKeyMap -> when (destination) { + is NavDestination.ConfigKeyMap.New -> + NavAppDirections.actionToConfigKeymap( + groupUid = destination.groupUid, + showAdvancedTriggers = destination.showAdvancedTriggers, + ) + + is NavDestination.ConfigKeyMap.Open -> + NavAppDirections.actionToConfigKeymap( + keyMapUid = destination.keyMapUid, + showAdvancedTriggers = destination.showAdvancedTriggers, + ) + } is NavDestination.ChooseFloatingLayout -> NavAppDirections.toChooseFloatingLayoutFragment() NavDestination.ShizukuSettings -> NavAppDirections.toShizukuSettingsFragment() diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/PopupUi.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/PopupUi.kt index ee86a7c093..cb42104ded 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/PopupUi.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/PopupUi.kt @@ -1,7 +1,5 @@ package io.github.sds100.keymapper.util.ui -import io.github.sds100.keymapper.home.ChooseAppStoreModel - /** * Created by sds100 on 23/03/2021. */ diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CollapsableFloatingActionButton.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CollapsableFloatingActionButton.kt new file mode 100644 index 0000000000..0da7372e08 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CollapsableFloatingActionButton.kt @@ -0,0 +1,41 @@ +package io.github.sds100.keymapper.util.ui.compose + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun CollapsableFloatingActionButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + text: String, + showText: Boolean, +) { + FloatingActionButton( + modifier = modifier, + onClick = onClick, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.Add, contentDescription = text) + + AnimatedVisibility(showText) { + AnimatedContent(text) { text -> + Text(modifier = Modifier.padding(start = 8.dp), text = text) + } + } + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CompactChip.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CompactChip.kt new file mode 100644 index 0000000000..2fe1b81ea9 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CompactChip.kt @@ -0,0 +1,105 @@ +package io.github.sds100.keymapper.util.ui.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Error +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.mappings.keymaps.chipHeight + +@Composable +fun CompactChip( + modifier: Modifier = Modifier, + text: String, + icon: (@Composable () -> Unit)? = null, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + onClick: (() -> Unit)? = null, + enabled: Boolean = false, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + if (onClick == null || !enabled) { + Surface( + modifier = modifier.height(chipHeight), + color = containerColor, + shape = AssistChipDefaults.shape, + ) { + CompactChipContent(icon, text, contentColor) + } + } else { + Surface( + modifier = modifier.height(chipHeight), + color = containerColor, + shape = AssistChipDefaults.shape, + onClick = onClick, + ) { + CompactChipContent(icon, text, contentColor) + } + } + } +} + +@Composable +fun ErrorCompactChip( + onClick: () -> Unit, + text: String, + enabled: Boolean, +) { + CompactChip( + text = text, + icon = { + Icon( + modifier = Modifier.fillMaxHeight(), + imageVector = Icons.Outlined.Error, + contentDescription = null, + ) + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + onClick = onClick, + enabled = enabled, + ) +} + +@Composable +private fun CompactChipContent( + icon: @Composable (() -> Unit)?, + text: String, + contentColor: Color, +) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + icon() + Spacer(Modifier.width(4.dp)) + } + + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelLarge, + color = contentColor, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/RadioButtonText.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/RadioButtonText.kt index 9a9bc0b4eb..16c7d38414 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/RadioButtonText.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/RadioButtonText.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.util.ui.compose import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface @@ -42,7 +43,11 @@ fun RadioButtonText( style = if (isEnabled) { MaterialTheme.typography.bodyMedium } else { - MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.surfaceVariant) + MaterialTheme.typography.bodyMedium.copy( + color = LocalContentColor.current.copy( + alpha = 0.5f, + ), + ) }, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/res/layout/dialog_choose_app_store.xml b/app/src/main/res/layout/dialog_choose_app_store.xml index 71e427573d..e42cff3567 100644 --- a/app/src/main/res/layout/dialog_choose_app_store.xml +++ b/app/src/main/res/layout/dialog_choose_app_store.xml @@ -8,7 +8,7 @@ + type="io.github.sds100.keymapper.util.ui.ChooseAppStoreModel" /> + diff --git a/app/src/main/res/navigation/nav_config_keymap.xml b/app/src/main/res/navigation/nav_config_keymap.xml index 4dc633c0cb..824d90ac2c 100644 --- a/app/src/main/res/navigation/nav_config_keymap.xml +++ b/app/src/main/res/navigation/nav_config_keymap.xml @@ -1,18 +1,22 @@ + android:label="Edit Keymap"> + + @@ -28,14 +32,5 @@ app:argType="string" app:nullable="true" /> - - - \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ca9ddfabdd..96247417f6 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -71,4 +71,5 @@ + diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9995eaaecc..9d6ba52057 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -227,8 +227,10 @@ %s is disconnected Screen is on Screen is off - %s flashlight is off - %s flashlight is on + Flashlight is off + Flashlight is on + Front flashlight is off + Front flashlight is on AND OR @@ -321,6 +323,7 @@ https://play.google.com/store/apps/details?id=io.github.sds100.keymapper https://keymapperorg.github.io/KeyMapper https://keymapperorg.github.io/KeyMapper/redirects/advanced-triggers + https://keymapperorg.github.io/KeyMapper/redirects/assistant-trigger https://keymapperorg.github.io/KeyMapper/redirects/floating-buttons https://keymapperorg.github.io/KeyMapper/redirects/floating-layouts https://keymapperorg.github.io/KeyMapper/redirects/floating-button-config @@ -492,6 +495,9 @@ Drag handle for %1$s Show example + Unrecognized key code + The pressed button was not recognized by the input system. In the past Key Mapper detected such buttons as one and the same. Currently the app tries to distinguish the button based on the scan code, which should be more unique. However, this is a makeshift incomplete solution, which doesn\'t guarantee uniqueness. + Done Guide Guide @@ -503,6 +509,7 @@ Apply Discard changes Save + Understood Turn off Cancel @@ -986,16 +993,23 @@ Enable flashlight Disable flashlight - Toggle %s flashlight - Toggle %s flashlight (%d%%) - Enable %s flashlight - Enable %s flashlight (%d\%%) - Disable %s flashlight - + Toggle flashlight + Toggle flashlight (%s) + Enable flashlight + Enable flashlight (%s) + Disable flashlight Change flashlight brightness - - Brighten %s flashlight %d\%% - Dim %s flashlight %d\%% + Brighten flashlight %s + Dim flashlight %s + + Toggle front flashlight + Toggle front flashlight (%s) + Enable front flashlight + Enable front flashlight (%s) + Disable front flashlight + Change front flashlight brightness + Brighten front flashlight %s + Dim front flashlight %s Enable NFC Disable NFC @@ -1181,6 +1195,11 @@ You must purchase floating buttons. Button was deleted. Floating button + Set up side key trigger + Attention! + You must read the instructions on our website that describe how to set up this trigger. Key Mapper will not guide you. + Read instructions + Select trigger type Unlock (%s) @@ -1244,6 +1263,8 @@ Delete Button text (Tip: use an emoji) Button size: + Border opacity: + Background opacity: Cancel Done The button must have text! @@ -1302,6 +1323,7 @@ Cancel Hide floating layouts You can find floating buttons in the Advanced Triggers button when creating a trigger. + Floating buttons Menu Sort More @@ -1309,6 +1331,7 @@ Select all Deselect all Stop selecting + Go up a group Paused 1 warning @@ -1316,6 +1339,7 @@ Running Settings + Delete group About Export all Import @@ -1343,6 +1367,7 @@ Enabled Disabled Mixed + Move to group Delete 1 key map @@ -1353,6 +1378,15 @@ Cancel Save to files + New group + New subgroup + View all + Hide + Group constraints + New constraint + Delete group constraint + This group + Remove Edit @@ -1399,7 +1433,7 @@ Max Test Requires Android 13 or newer. - This flash does not let you change the brightness. + This device does not let you change the brightness. Brightness change Unsupported @@ -1416,4 +1450,19 @@ Choose a constraint This key map will only run if: + + Untitled group + Edit group name + Save group name + Name must be unique! + Home + Delete group + Delete group %s + Are you sure you want to delete this group? All the key maps in this group and its subgroups will also be deleted! + Yes, delete + Cancel + + +%d inherited constraint + +%d inherited constraints + diff --git a/app/src/main/res/xml/config_accessibility_service.xml b/app/src/main/res/xml/config_accessibility_service.xml index 68715fbe04..425f3f1146 100644 --- a/app/src/main/res/xml/config_accessibility_service.xml +++ b/app/src/main/res/xml/config_accessibility_service.xml @@ -5,4 +5,4 @@ android:canRequestFingerprintGestures="true" android:canRetrieveWindowContent="true" android:description="@string/accessibility_service_explanation" - android:notificationTimeout="100" /> \ No newline at end of file + android:notificationTimeout="200" /> \ No newline at end of file diff --git a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt index 23820c875e..2bfa190c60 100644 --- a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt @@ -10,8 +10,14 @@ import io.github.sds100.keymapper.backup.RestoreType import io.github.sds100.keymapper.data.db.AppDatabase import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.FloatingButtonEntity +import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity +import io.github.sds100.keymapper.data.entities.FloatingLayoutEntityWithButtons +import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.repositories.FakePreferenceRepository +import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository +import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository import io.github.sds100.keymapper.system.files.FakeFileAdapter @@ -23,7 +29,6 @@ import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.UuidGenerator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -32,6 +37,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers import org.hamcrest.Matchers.`is` import org.hamcrest.core.IsInstanceOf import org.junit.After @@ -43,6 +49,7 @@ import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.anyVararg +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -86,9 +93,7 @@ class BackupManagerTest { fakePreferenceRepository = FakePreferenceRepository() - mockKeyMapRepository = mock { - on { requestBackup }.then { MutableSharedFlow>() } - } + mockKeyMapRepository = mock() fakeFileAdapter = FakeFileAdapter(temporaryFolder) @@ -108,7 +113,12 @@ class BackupManagerTest { soundsManager = mockSoundsManager, uuidGenerator = mockUuidGenerator, floatingButtonRepository = mock {}, - floatingLayoutRepository = mock {}, + floatingLayoutRepository = mock { + on { layouts } doReturn MutableStateFlow(State.Data(emptyList())) + }, + groupRepository = mock { + on { getAllGroups() } doReturn MutableStateFlow(emptyList()) + }, ) parser = JsonParser() @@ -120,13 +130,66 @@ class BackupManagerTest { Dispatchers.resetMain() } + @Test + fun `when backing up everything include layouts that are not in the list of key maps`() = runTest(testDispatcher) { + val layoutWithButtons = FloatingLayoutEntityWithButtons( + layout = FloatingLayoutEntity( + uid = "layout_uid", + name = "layout_name", + ), + buttons = listOf( + FloatingButtonEntity( + uid = "button_uid", + layoutUid = "layout_uid", + text = "Button", + buttonSize = 10, + x = 0, + y = 0, + orientation = "orientation", + displayWidth = 100, + displayHeight = 100, + borderOpacity = null, + backgroundOpacity = null, + ), + ), + ) + + val content = backupManager.createBackupContent( + keyMapList = emptyList(), + extraGroups = emptyList(), + extraLayouts = listOf(layoutWithButtons), + ) + + assertThat(content.floatingLayouts, Matchers.contains(layoutWithButtons.layout)) + assertThat( + content.floatingButtons, + Matchers.contains(*layoutWithButtons.buttons.toTypedArray()), + ) + } + + @Test + fun `when backing up everything include groups that are not in the list of key maps`() = runTest(testDispatcher) { + val group = GroupEntity( + uid = "group_uid", + name = "group_name", + parentUid = null, + lastOpenedDate = 0L, + ) + + val content = backupManager.createBackupContent( + keyMapList = emptyList(), + extraGroups = listOf(group), + extraLayouts = emptyList(), + ) + + assertThat(content.groups, Matchers.contains(group)) + } + /** * #745 */ @Test fun `Don't allow back ups from a newer version of key mapper`() = runTest(testDispatcher) { - advanceUntilIdle() - // GIVEN val dataJsonFile = "restore-app-version-too-big.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") @@ -145,11 +208,8 @@ class BackupManagerTest { * #745 */ @Test - fun `Allow back ups from a back up without a key mapper version in it`() = runTest(testDispatcher) { + fun `Allow restoring a back up without a key mapper version in it`() = runTest(testDispatcher) { // GIVEN - whenever(mockKeyMapRepository.keyMapList).then { - MutableStateFlow(State.Data(emptyList())) - } val dataJsonFile = "restore-no-app-version.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") @@ -167,9 +227,6 @@ class BackupManagerTest { @Test fun `don't crash if back up does not contain sounds folder`() = runTest(testDispatcher) { // GIVEN - whenever(mockKeyMapRepository.keyMapList).then { - MutableStateFlow(State.Data(emptyList())) - } val dataJsonFile = "restore-no-sounds-folder.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") diff --git a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt index 6860e30897..6d2300f589 100644 --- a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt @@ -265,7 +265,10 @@ class ConfigKeyMapUseCaseTest { // THEN val keyMap = useCase.keyMap.value.dataOrNull()!! - assertThat(keyMap.constraintState.constraints, contains(Constraint.PhoneRinging)) + assertThat( + keyMap.constraintState.constraints, + contains(instanceOf(Constraint.PhoneRinging::class.java)), + ) } /** @@ -283,7 +286,10 @@ class ConfigKeyMapUseCaseTest { // THEN val keyMap = useCase.keyMap.value.dataOrNull()!! - assertThat(keyMap.constraintState.constraints, contains(Constraint.InPhoneCall)) + assertThat( + keyMap.constraintState.constraints, + contains(instanceOf(Constraint.InPhoneCall::class.java)), + ) } /** diff --git a/app/src/test/java/io/github/sds100/keymapper/constraints/ConstraintSnapshotTest.kt b/app/src/test/java/io/github/sds100/keymapper/constraints/ConstraintSnapshotTest.kt new file mode 100644 index 0000000000..d2cc2e3fc8 --- /dev/null +++ b/app/src/test/java/io/github/sds100/keymapper/constraints/ConstraintSnapshotTest.kt @@ -0,0 +1,206 @@ +package io.github.sds100.keymapper.constraints + +import io.github.sds100.keymapper.util.TestConstraintSnapshot +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +class ConstraintSnapshotTest { + @Test + fun `When two constraints in three states, one OR and one AND, and all satisfied return true`() { + val snapshot = TestConstraintSnapshot( + appInForeground = "key_mapper", + isCharging = false, + isLocked = false, + isLockscreenShowing = true, + ) + + val state1 = ConstraintState( + constraints = + setOf( + Constraint.AppInForeground(packageName = "key_mapper"), + Constraint.Discharging(), + ), + mode = ConstraintMode.AND, + ) + + val state2 = + ConstraintState( + constraints = + setOf( + Constraint.LockScreenNotShowing(), + Constraint.DeviceIsUnlocked(), + ), + mode = ConstraintMode.OR, + ) + + val state3 = + ConstraintState( + constraints = + setOf( + Constraint.LockScreenShowing(), + Constraint.DeviceIsUnlocked(), + ), + mode = ConstraintMode.AND, + ) + + assertThat(snapshot.isSatisfied(state1, state2, state3), `is`(true)) + } + + @Test + fun `When two constraints in two states, one OR and one AND, and all unsatisfied return false`() { + val snapshot = TestConstraintSnapshot( + appInForeground = "key_mapper", + isCharging = true, + isLocked = true, + ) + + val state1 = ConstraintState( + constraints = + setOf( + Constraint.AppInForeground(packageName = "key_mapper"), + Constraint.Discharging(), + ), + mode = ConstraintMode.AND, + ) + + val state2 = + ConstraintState( + constraints = + setOf( + Constraint.Charging(), + Constraint.DeviceIsUnlocked(), + ), + mode = ConstraintMode.OR, + ) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) + } + + @Test + fun `When two constraints in two states, one OR and one AND, and all satisfied return true`() { + val snapshot = TestConstraintSnapshot( + appInForeground = "key_mapper", + isCharging = true, + isLocked = true, + ) + + val state1 = ConstraintState( + constraints = + setOf( + Constraint.AppInForeground(packageName = "key_mapper"), + Constraint.Charging(), + ), + mode = ConstraintMode.AND, + ) + + val state2 = + ConstraintState( + constraints = + setOf( + Constraint.Charging(), + Constraint.DeviceIsUnlocked(), + ), + mode = ConstraintMode.OR, + ) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + } + + @Test + fun `When one constraint in two states and all satisfied return true`() { + val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper", isCharging = true) + + val state1 = ConstraintState( + constraints = + setOf(Constraint.AppInForeground(packageName = "key_mapper")), + ) + + val state2 = + ConstraintState( + constraints = + setOf(Constraint.Charging()), + ) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + } + + @Test + fun `When one constraint in two states and all unsatisfied return false`() { + val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + + val state1 = ConstraintState( + constraints = + setOf(Constraint.AppInForeground(packageName = "google")), + ) + + val state2 = + ConstraintState( + constraints = + setOf(Constraint.AppInForeground(packageName = "google1")), + ) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) + } + + @Test + fun `When one constraint in two states and one unsatisfied return false`() { + val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + + val state1 = ConstraintState( + constraints = + setOf(Constraint.AppInForeground(packageName = "google")), + ) + + val state2 = + ConstraintState( + constraints = + setOf(Constraint.AppInForeground(packageName = "key_mapper")), + ) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) + } + + @Test + fun `When no constraints in two states return true`() { + val snapshot = TestConstraintSnapshot() + + val state1 = ConstraintState(constraints = emptySet()) + val state2 = ConstraintState(constraints = emptySet()) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + } + + @Test + fun `When no constraints in two states with mixed constraint modes return true`() { + val snapshot = TestConstraintSnapshot() + + val state1 = ConstraintState(constraints = emptySet(), mode = ConstraintMode.OR) + val state2 = ConstraintState(constraints = emptySet(), mode = ConstraintMode.AND) + + assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + } + + @Test + fun `When one constraint and unsatisfied return false`() { + val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + val constraint = Constraint.AppInForeground(packageName = "google") + val state = ConstraintState(constraints = setOf(constraint)) + assertThat(snapshot.isSatisfied(state), `is`(false)) + } + + @Test + fun `When one constraint and satisfied return true`() { + val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + val constraint = Constraint.AppInForeground(packageName = "key_mapper") + val state = ConstraintState(constraints = setOf(constraint)) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `When no constraints return true`() { + val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + val state = ConstraintState(constraints = emptySet()) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } +} diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 45b3920c81..149d0d7522 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -8,11 +8,13 @@ import io.github.sds100.keymapper.actions.ActionErrorSnapshot import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.actions.RepeatMode import io.github.sds100.keymapper.constraints.Constraint +import io.github.sds100.keymapper.constraints.ConstraintMode import io.github.sds100.keymapper.constraints.ConstraintSnapshot import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.FingerprintGestureType +import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapModel import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController import io.github.sds100.keymapper.mappings.keymaps.trigger.FingerprintTriggerKey @@ -39,6 +41,7 @@ import junitparams.naming.TestCaseName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -124,6 +127,7 @@ class KeyMapControllerTest { private lateinit var performActionsUseCase: PerformActionsUseCase private lateinit var detectConstraintsUseCase: DetectConstraintsUseCase private lateinit var keyMapListFlow: MutableStateFlow> + private lateinit var detectKeyMapListFlow: MutableStateFlow> @get:Rule var instantExecutorRule = InstantTaskExecutorRule() @@ -134,9 +138,15 @@ class KeyMapControllerTest { @Before fun init() { keyMapListFlow = MutableStateFlow(emptyList()) + detectKeyMapListFlow = MutableStateFlow(emptyList()) detectKeyMapsUseCase = mock { - on { allKeyMapList } doReturn keyMapListFlow + on { allKeyMapList } doReturn combine( + keyMapListFlow, + detectKeyMapListFlow, + ) { keyMapList, detectKeyMapList -> + keyMapList.map { DetectKeyMapModel(keyMap = it) }.plus(detectKeyMapList) + } MutableStateFlow(VIBRATION_DURATION).apply { on { defaultVibrateDuration } doReturn this @@ -191,6 +201,100 @@ class KeyMapControllerTest { ) } + @Test + fun `Do not perform if one group constraint set is not satisfied`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger(triggerKey(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN)) + detectKeyMapListFlow.value = listOf( + DetectKeyMapModel( + keyMap = KeyMap( + trigger = trigger, + actionList = listOf(TEST_ACTION), + constraintState = ConstraintState( + constraints = setOf(Constraint.WifiOn(), Constraint.DeviceIsLocked()), + mode = ConstraintMode.OR, + ), + ), + groupConstraintStates = listOf( + ConstraintState( + constraints = setOf( + Constraint.LockScreenNotShowing(), + Constraint.DeviceIsLocked(), + ), + mode = ConstraintMode.AND, + ), + ConstraintState( + constraints = setOf( + Constraint.AppInForeground(packageName = "app"), + Constraint.DeviceIsUnlocked(), + ), + mode = ConstraintMode.OR, + ), + ), + ), + ) + + whenever(detectConstraintsUseCase.getSnapshot()) + .thenReturn( + TestConstraintSnapshot( + isWifiEnabled = true, + isLocked = true, + isLockscreenShowing = true, + appInForeground = "app", + ), + ) + + mockTriggerKeyInput(trigger.keys[0]) + + verify(performActionsUseCase, never()).perform(TEST_ACTION.data) + } + + @Test + fun `Perform if all group constraints and key map constraints are satisfied`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger(triggerKey(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN)) + detectKeyMapListFlow.value = listOf( + DetectKeyMapModel( + keyMap = KeyMap( + trigger = trigger, + actionList = listOf(TEST_ACTION), + constraintState = ConstraintState( + constraints = setOf(Constraint.WifiOn(), Constraint.DeviceIsLocked()), + mode = ConstraintMode.OR, + ), + ), + groupConstraintStates = listOf( + ConstraintState( + constraints = setOf( + Constraint.LockScreenNotShowing(), + Constraint.DeviceIsLocked(), + ), + mode = ConstraintMode.AND, + ), + ConstraintState( + constraints = setOf( + Constraint.AppInForeground(packageName = "app"), + Constraint.DeviceIsUnlocked(), + ), + mode = ConstraintMode.OR, + ), + ), + ), + ) + + whenever(detectConstraintsUseCase.getSnapshot()) + .thenReturn( + TestConstraintSnapshot( + isWifiEnabled = true, + isLocked = true, + isLockscreenShowing = false, + appInForeground = "app", + ), + ) + + mockTriggerKeyInput(trigger.keys[0]) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + /** * #1507 */ @@ -950,12 +1054,12 @@ class KeyMapControllerTest { val shortPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), ) - val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn)) + val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn())) val longPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), ) - val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff)) + val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff())) keyMapListFlow.value = listOf( KeyMap( @@ -973,7 +1077,8 @@ class KeyMapControllerTest { ) // Only the short press trigger is allowed. - mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn } + val constraintSnapshot = TestConstraintSnapshot(isWifiEnabled = true) + whenever(detectConstraintsUseCase.getSnapshot()).thenReturn(constraintSnapshot) mockTriggerKeyInput(shortPressTrigger.keys.first()) @@ -989,12 +1094,12 @@ class KeyMapControllerTest { val shortPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), ) - val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn)) + val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn())) val doublePressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS), ) - val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff)) + val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff())) keyMapListFlow.value = listOf( KeyMap( @@ -1012,7 +1117,8 @@ class KeyMapControllerTest { ) // Only the short press trigger is allowed. - mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn } + val constraintSnapshot = TestConstraintSnapshot(isWifiEnabled = true) + whenever(detectConstraintsUseCase.getSnapshot()).thenReturn(constraintSnapshot) mockTriggerKeyInput(shortPressTrigger.keys.first()) @@ -1109,7 +1215,7 @@ class KeyMapControllerTest { ), actionList = listOf(Action(data = actionData)), constraintState = ConstraintState( - constraints = setOf(Constraint.FlashlightOn(CameraLens.BACK)), + constraints = setOf(Constraint.FlashlightOn(lens = CameraLens.BACK)), ), ) @@ -4101,11 +4207,4 @@ class KeyMapControllerTest { isGameController = isGameController, ) } - - private fun mockConstraintSnapshot(isSatisfiedBlock: (constraint: Constraint) -> Boolean) { - val snapshot = object : ConstraintSnapshot { - override fun isSatisfied(constraint: Constraint): Boolean = isSatisfiedBlock(constraint) - } - whenever(detectConstraintsUseCase.getSnapshot()).thenReturn(snapshot) - } } diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ProcessKeyMapGroupsForDetectionTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ProcessKeyMapGroupsForDetectionTest.kt new file mode 100644 index 0000000000..2f093a3899 --- /dev/null +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ProcessKeyMapGroupsForDetectionTest.kt @@ -0,0 +1,296 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import io.github.sds100.keymapper.constraints.Constraint +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.constraints.ConstraintState +import io.github.sds100.keymapper.groups.Group +import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapModel +import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCaseImpl +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.junit.Test + +class ProcessKeyMapGroupsForDetectionTest { + + @Test + fun `Key map in grandchild group, all have constraints, and parent does not exist then ignore key map`() { + val keyMap = KeyMap(groupUid = "child") + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group( + "child", + parentUid = "parent", + mode = ConstraintMode.OR, + Constraint.LockScreenNotShowing(), + Constraint.Discharging(), + ), + group( + "parent", + parentUid = "bad_parent", + mode = ConstraintMode.AND, + Constraint.DeviceIsLocked(), + Constraint.NotInPhoneCall(), + ), + ), + ) + + assertThat(models, Matchers.empty()) + } + + @Test + fun `Key map in grandchild group and all groups have constraints`() { + val keyMap = KeyMap(groupUid = "child") + + val constraints1 = arrayOf( + Constraint.LockScreenNotShowing(), + Constraint.Discharging(), + ) + + val constraints2 = arrayOf( + Constraint.DeviceIsLocked(), + Constraint.NotInPhoneCall(), + ) + + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group( + "child", + parentUid = "parent", + mode = ConstraintMode.OR, + *constraints1, + ), + group( + "parent", + parentUid = null, + mode = ConstraintMode.AND, + *constraints2, + ), + ), + ) + + val expected = DetectKeyMapModel( + keyMap, + groupConstraintStates = listOf( + ConstraintState( + constraints = constraints1.toSet(), + mode = ConstraintMode.OR, + ), + ConstraintState( + constraints = constraints2.toSet(), + mode = ConstraintMode.AND, + ), + ), + ) + assertThat(models, Matchers.contains(expected)) + } + + @Test + fun `Key map in grandchild group and child only has constraints`() { + val keyMap = KeyMap(groupUid = "child") + val constraints1 = arrayOf( + Constraint.LockScreenNotShowing(), + Constraint.Discharging(), + ) + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group( + "child", + parentUid = "parent", + mode = ConstraintMode.OR, + *constraints1, + ), + group( + "parent", + parentUid = null, + ), + ), + ) + + val expected = DetectKeyMapModel( + keyMap, + groupConstraintStates = listOf( + ConstraintState( + constraints = constraints1.toSet(), + mode = ConstraintMode.OR, + ), + ), + ) + assertThat(models, Matchers.contains(expected)) + } + + @Test + fun `Key map in grandchild group and parent only has constraints`() { + val keyMap = KeyMap(groupUid = "child") + val constraints1 = arrayOf( + Constraint.LockScreenNotShowing(), + Constraint.Discharging(), + ) + + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group("child", parentUid = "parent"), + group( + "parent", + parentUid = null, + mode = ConstraintMode.OR, + *constraints1, + ), + ), + ) + + val expected = DetectKeyMapModel( + keyMap, + groupConstraintStates = listOf( + ConstraintState( + constraints = constraints1.toSet(), + mode = ConstraintMode.OR, + ), + ), + ) + assertThat(models, Matchers.contains(expected)) + } + + @Test + fun `Key map in grandchild group and parent exists then include`() { + val keyMap = KeyMap(groupUid = "child") + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group("child", parentUid = "parent"), + group("parent", parentUid = null), + ), + ) + + assertThat( + models, + Matchers.contains( + DetectKeyMapModel(keyMap = keyMap), + ), + ) + } + + @Test + fun `Key maps in child and root groups then include both`() { + val keyMap1 = KeyMap(groupUid = "child") + val keyMap2 = KeyMap(groupUid = null) + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap1, keyMap2), + groups = listOf( + group("child", parentUid = null), + ), + ) + + assertThat( + models, + Matchers.contains( + DetectKeyMapModel( + keyMap = keyMap1, + ), + DetectKeyMapModel( + keyMap = keyMap2, + ), + ), + ) + } + + @Test + fun `One key map in child group and parent is missing then ignore key map`() { + val keyMap = KeyMap(groupUid = "child") + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group("child", parentUid = "bad_parent"), + ), + ) + + assertThat(models, Matchers.empty()) + } + + @Test + fun `One key map in child group then include`() { + val keyMap = KeyMap(groupUid = "child") + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group("child", parentUid = null), + ), + ) + + assertThat( + models, + Matchers.contains( + DetectKeyMapModel(keyMap = keyMap), + ), + ) + } + + @Test + fun `Do not include empty constraint states from groups`() { + val keyMap = KeyMap(groupUid = "group1") + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group("group1"), + ), + ) + + assertThat(models, Matchers.contains(DetectKeyMapModel(keyMap))) + } + + @Test + fun `One key map in root group`() { + val keyMap = KeyMap() + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = listOf( + group("group1"), + ), + ) + + assertThat(models, Matchers.contains(DetectKeyMapModel(keyMap))) + } + + @Test + fun `empty key maps and one group`() { + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = emptyList(), + groups = listOf( + group("group1"), + ), + ) + + assertThat(models, Matchers.empty()) + } + + @Test + fun `empty key maps`() { + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = emptyList(), + groups = emptyList(), + ) + + assertThat(models, Matchers.empty()) + } + + private fun group( + uid: String, + parentUid: String? = null, + mode: ConstraintMode = ConstraintMode.AND, + vararg constraint: Constraint, + ): Group { + return Group( + uid = uid, + name = uid, + constraintState = ConstraintState( + constraints = constraint.toSet(), + mode = mode, + ), + parentUid = parentUid, + lastOpenedDate = 0, + ) + } +} diff --git a/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt b/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt index a3633b73c5..ada802c501 100644 --- a/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt @@ -35,8 +35,8 @@ class TestConstraintSnapshot( is Constraint.AppNotPlayingMedia -> appsPlayingMedia.none { it == constraint.packageName } - Constraint.MediaPlaying -> appsPlayingMedia.isNotEmpty() - Constraint.NoMediaPlaying -> appsPlayingMedia.isEmpty() + is Constraint.MediaPlaying -> appsPlayingMedia.isNotEmpty() + is Constraint.NoMediaPlaying -> appsPlayingMedia.isEmpty() is Constraint.BtDeviceConnected -> { connectedBluetoothDevices.any { it.address == constraint.bluetoothAddress } } @@ -46,14 +46,14 @@ class TestConstraintSnapshot( } is Constraint.OrientationCustom -> orientation == constraint.orientation - Constraint.OrientationLandscape -> + is Constraint.OrientationLandscape -> orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 - Constraint.OrientationPortrait -> + is Constraint.OrientationPortrait -> orientation == Orientation.ORIENTATION_0 || orientation == Orientation.ORIENTATION_180 - Constraint.ScreenOff -> !isScreenOn - Constraint.ScreenOn -> isScreenOn + is Constraint.ScreenOff -> !isScreenOn + is Constraint.ScreenOn -> isScreenOn is Constraint.FlashlightOff -> when (constraint.lens) { CameraLens.BACK -> !isBackFlashlightOn CameraLens.FRONT -> !isFrontFlashlightOn @@ -81,19 +81,19 @@ class TestConstraintSnapshot( connectedWifiSSID != constraint.ssid } - Constraint.WifiOff -> !isWifiEnabled - Constraint.WifiOn -> isWifiEnabled + is Constraint.WifiOff -> !isWifiEnabled + is Constraint.WifiOn -> isWifiEnabled is Constraint.ImeChosen -> chosenImeId == constraint.imeId is Constraint.ImeNotChosen -> chosenImeId != constraint.imeId - Constraint.DeviceIsLocked -> isLocked - Constraint.DeviceIsUnlocked -> !isLocked - Constraint.InPhoneCall -> callState == CallState.IN_PHONE_CALL - Constraint.NotInPhoneCall -> callState == CallState.NONE - Constraint.PhoneRinging -> callState == CallState.RINGING - Constraint.Charging -> isCharging - Constraint.Discharging -> !isCharging - Constraint.LockScreenShowing -> isLockscreenShowing - Constraint.LockScreenNotShowing -> !isLockscreenShowing + is Constraint.DeviceIsLocked -> isLocked + is Constraint.DeviceIsUnlocked -> !isLocked + is Constraint.InPhoneCall -> callState == CallState.IN_PHONE_CALL + is Constraint.NotInPhoneCall -> callState == CallState.NONE + is Constraint.PhoneRinging -> callState == CallState.RINGING + is Constraint.Charging -> isCharging + is Constraint.Discharging -> !isCharging + is Constraint.LockScreenShowing -> isLockscreenShowing + is Constraint.LockScreenNotShowing -> !isLockscreenShowing } if (isSatisfied) { diff --git a/app/version.properties b/app/version.properties index d53b96ecc2..ce8caecb21 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=3.0.0-beta.2 -VERSION_CODE=83 +VERSION_NAME=3.0.0-beta.3 +VERSION_CODE=90 VERSION_NUM=0 \ No newline at end of file diff --git a/docs/images/advanced-triggers-paywall.png b/docs/images/advanced-triggers-paywall.png index d12a5f897e..dcd1490bad 100644 Binary files a/docs/images/advanced-triggers-paywall.png and b/docs/images/advanced-triggers-paywall.png differ diff --git a/docs/includes/action-type-list.md b/docs/includes/action-type-list.md index a08a63c144..f2fe240cb6 100644 --- a/docs/includes/action-type-list.md +++ b/docs/includes/action-type-list.md @@ -6,7 +6,7 @@ Tab | Description | | [System](../user-guide/actions#system) | Choose a system operation (such as toggling Bluetooth, opening the home menu, toggling flashlight) | | [Key](../user-guide/actions#key) | An alternative way to choose a key press action, by pressing the key that you want to map to. | | [Tap screen (2.1.0+)](../user-guide/actions#tap-screen-210) | Emulate a screen tap at a specific location on your screen. | -| [Key event (2.1.0+)](../user-guide/actions#key-event-210) | Emulate a key press from a specifc connected device. | +| [Key event (2.1.0+)](../user-guide/actions#key-event-210) | Emulate a key press from a specific connected device. | | [Text](../user-guide/actions#text) | Emulate typing a string. | | [Intent (2.3.0+)](../user-guide/actions#intent-230) | See [this page.](../user-guide/actions/#intent-230) | | [Phone call (2.3.0+)](../user-guide/actions#phone-call-230) | Call a telephone number. Network and carrier rates will apply. | diff --git a/docs/includes/configuring-constraints.md b/docs/includes/configuring-constraints.md index 1788995a89..3621436a9e 100644 --- a/docs/includes/configuring-constraints.md +++ b/docs/includes/configuring-constraints.md @@ -1,6 +1,6 @@ Constraints allow you to restrict your mappings to only work in some situations. -To add a constraint fron the 'Constraints and more' or 'Options' tab, tap 'Add constraint'. +To add a constraint from the 'Constraints and more' or 'Options' tab, tap 'Add constraint'. Go [here](/user-guide/constraints) to see how you can configure constraints. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index ddde1e53e2..7e1cd59b19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,5 @@ # Welcome to Key Mapper Documentation -!!! warning "MAINTENANCE NOTICE!" - - Feature development has slowed down. - [Key Mapper](https://github.com/keymapperorg/KeyMapper) is a free and open source Android app that can map single or multiple buttons to a custom action. ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/keymapperorg/KeyMapper.svg) @@ -41,7 +37,6 @@ This wiki aims to provide users with a comprehensive guide to using and setting ### Community moderators/support - [Jack Ambler (jambl3r)](https://linkedin.com/in/jambl3r) -- [GL513](https://gl513.github.io/) ### Translators diff --git a/docs/user-guide/actions.md b/docs/user-guide/actions.md index d99dc17012..e1e2e879f5 100644 --- a/docs/user-guide/actions.md +++ b/docs/user-guide/actions.md @@ -3,7 +3,7 @@ Launch an app. !!! warning "Extra permission on Xiaomi devices!" - See issue [#1370](https://github.com/keymapperorg/KeyMapper/issues/1370https://github.com/keymapperorg/KeyMapper/issues/1370). Xioami blocks apps from launching apps when they are in the background unless you give permission to "Display pop-up windows while running in the background" and "Display pop-up window". Follow these steps through the Settings app: Apps > Manage apps > Key Mapper App Settings > Other Permissions > Display pop-up windows while running in the background. + See issue [#1370](https://github.com/keymapperorg/KeyMapper/issues/1370). Xiaomi blocks apps from launching apps when they are in the background unless you give permission to "Display pop-up windows while running in the background" and "Display pop-up window". Follow these steps through the Settings app: Apps > Manage apps > Key Mapper App Settings > Other Permissions > Display pop-up windows while running in the background. ### Launch app shortcut @@ -24,7 +24,7 @@ Android restricts what apps can do with this so you won't be able to tap the scr ### Swipe screen (2.5.0+, Android 7.0+) -This will swipe from a start point to and end point on your screen. You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limitied due to your Android Version. +This will swipe from a start point to and end point on your screen. You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limited due to your Android Version. See: [getMaxStrokeCount](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxStrokeCount()) and [getMaxStrokeDuration](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxGestureDuration()) for more information. @@ -32,7 +32,7 @@ See: [getMaxStrokeCount](https://developer.android.com/reference/android/accessi This will simulate a pinch gesture from a start point to and end point on your screen. You can choose between *pinch in* and *pinch out* and also the "pinch distance" for the pinch gesture. This is the distance between the start and the end point. The higher the distance, the stronger the pinch gesture, so you may want to start with a lower value for the pinch with max. 100. Later on you can adjust this by your needs, **but** the endpoints will never be less than 0 due to android restrictions. -You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limitied due to your Android Version. +You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limited due to your Android Version. See: [getMaxStrokeCount](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxStrokeCount()) and [getMaxStrokeDuration](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxGestureDuration()) for more information. diff --git a/docs/user-guide/keymaps.md b/docs/user-guide/keymaps.md index cbb140082d..25ae4e0ebb 100644 --- a/docs/user-guide/keymaps.md +++ b/docs/user-guide/keymaps.md @@ -69,37 +69,36 @@ type above the trigger mode buttons as shown in the image at the top of this Tri These triggers can be purchased so that the Key Mapper project has a little financial support and the developer is able to invest time maintaining and working on the project. You can see the list of advanced triggers below by tapping 'advanced triggers' on the trigger page. -![](../images/advanced-triggers-paywall.png) +![](../images/advanced-triggers-paywall.png){ width="200" } -### Assistant trigger +### Floating buttons -This trigger allows you to remap the various ways that your devices trigger the 'assistant' such as Google Assistant, Bixby, Alexa etc. +Read about floating buttons [here](floating-buttons.md). -There are 3 assistant options you can choose: +### Side key & Assistant trigger -- **Device assistant**: This is the assistant usually triggered from a long press of a power button or a dedicated button. -- **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. -- **Any assistant**: This will trigger the key map when any of the above are triggered. +This trigger allows you to remap the various ways that your device can trigger the side key or assistant. -!!! note - It is not possible to create long-press key maps with this trigger! But you can do double press. You also can not use multiple assistant triggers in a parallel trigger because there is no way to detect them being pressed at exactly the same time. +!!! danger + Key Mapper can not walk you through setting this trigger up because the steps are unique to almost every device and Android skin. **You must read the instructions below.** -### Setting up +There are 2 options you can choose to configure how the trigger works. -There are multiple ways of triggering the assistant on different devices. +- **Side key/power button**: The key map will be triggered whenever the "Key Mapper: Side key" app is launched. Read more in the next section on how to set this up on some devices. +- **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. When you launch the voice assistant, your device should ask you which app to use by default and you should see Key Mapper in that list. -**Long press power button, Pixel squeeze** +!!! note + It is not possible to create long-press key maps with this trigger! But you can do double press. You also can not use multiple assistant triggers in a parallel trigger because there is no way to detect them being pressed at exactly the same time. -This works on most Android devices. Android devices now have the option for remapping a long press of the power button to the assistant app. Older Pixels, such as the Pixel 2, also had a feature called "Active Edge" that allowed you to _squeeze_ the bottom half of the phone to trigger the assistant. If you select Key Mapper as the 'device assistant' app then your key map will be triggered with both of these methods. +**Long press power button, Pixel squeeze** -You can set up the long-press of the power button by going to Android Settings :material-arrow-right-thin: System :material-arrow-right-thin: Gestures :material-arrow-right-thin: Press and hold power button. Then choose the digital assistant instead of showing power menu when you long press the power button. Key Mapper will prompt you to select it as the default digital assistant app. +Most stock Android devices have this option. In your settings app you should have the option to remap a long press of the power button to the assistant app. Older Pixels, such as the Pixel 2, also have a feature called "Active Edge" that allowed you to _squeeze_ the bottom half of the phone to trigger the assistant. If you select Key Mapper as the default 'digital assistant' app then your key map will be triggered with both of these methods. -**Bixby button** +You can set up the long-press of the power button by going to Android Settings :material-arrow-right-thin: System :material-arrow-right-thin: Gestures :material-arrow-right-thin: Press and hold power button. Then choose the digital assistant instead of showing the power menu when you long press the power button. -This *should* work on Samsung devices that have a dedicated Bixby button but also devices that have the option of remapping the power button to another app when you double press it. You can use the assistant trigger for double pressing the Bixby or power button by picking the 'Assistant trigger' app/activity that shows in your list of apps. +**Samsung side key/Bixby button** -!!! note - The developer does not have a Samsung device with a Bixby button so there is no guarantee that it works. If it does, please let the developer know so we can be more confident about support in the future 😄. +This *should* work on Samsung devices that have a dedicated side key button but also devices that have the option of remapping the power button to another app when you long or double press it. You can use the assistant trigger for double pressing the Bixby or power button by picking the 'Key Mapper: Side key' app that shows in your list of apps. **Voice assistant button on keyboards and Bluetooth headphones** @@ -108,6 +107,11 @@ Many external devices such as headsets and keyboards have a button for launching !!! warning Some headphones have hardcoded the assistant apps that they support and will not work with Key Mapper. The developer has Sony WH1000XM3 headphones that only support Alexa and Google Assistant and refuse to launch Key Mapper when it is selected as the default assistant app. +**Help 😱! None of these steps work on my device** + +!!! question + You will need to search in your device settings or on the internet for how to remap the side key/power button on your device. If you're still stuck you can contact the developer in the app with the 'Contact developer' button shown in [this](keymaps.md#advanced-triggers) screenshot. _You **must** fill in the template otherwise we will ignore the email._ + ## Customising actions You can tap the pencil icon :material-pencil-outline: to the right of the action's name to bring up the following menu. diff --git a/mkdocs.yml b/mkdocs.yml index c8424fd01e..48371a5632 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ plugins: 'redirects/floating-buttons.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/floating-buttons' 'redirects/floating-layouts.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/floating-buttons' 'redirects/floating-button-config.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/floating-buttons' + 'redirects/assistant-trigger.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#side-key-assistant-trigger' - search: lang: - en