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/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/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/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..7404ed2235 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), ) 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/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/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 b351ddc9e2..df56974e0d 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,7 @@ 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.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 +40,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 @@ -82,6 +84,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 +120,7 @@ class BackupManagerImpl( .registerTypeAdapter(ConstraintEntity.DESERIALIZER) .registerTypeAdapter(FloatingLayoutEntity.DESERIALIZER) .registerTypeAdapter(FloatingButtonEntity.DESERIALIZER) + .registerTypeAdapter(GroupEntity.DESERIALIZER) .create() } @@ -180,7 +184,9 @@ class BackupManagerImpl( .filterIsInstance>>() .first() - backupAsync(output, keyMaps.data) + val groups = groupRepository.getAllGroups().first() + + backupAsync(output, keyMaps.data, groups) Success(Unit) } @@ -244,6 +250,9 @@ class BackupManagerImpl( // 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 }, ) if (keyMapListJsonArray != null) { @@ -354,6 +363,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, @@ -366,6 +379,7 @@ class BackupManagerImpl( defaultSequenceTriggerTimeout = defaultSequenceTriggerTimeout, floatingLayouts = floatingLayouts, floatingButtons = floatingButtons, + groups = groups, ) return@withContext Success(content) @@ -438,12 +452,46 @@ 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() + + groupRepository.getAllGroups().first() + .map { it.uid } + .toSet() + .also { groupUids.addAll(it) } + + for (group in backupContent.groups) { + var movedGroup = group + + // 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)) { + movedGroup = movedGroup.copy(parentUid = null) + } - RestoreType.REPLACE -> - replaceKeyMapsInRepository(backupContent.keyMapList ?: emptyList()) + RepositoryUtils.saveUniqueName( + movedGroup, + saveBlock = { groupRepository.insert(it) }, + renameBlock = { entity, suffix -> + entity.copy(name = "${entity.name} $suffix") + }, + ) + } + } + + 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) { @@ -490,19 +538,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") + }, + ) } } @@ -538,9 +580,29 @@ 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, + extraGroups: List = emptyList(), ): Result { return withContext(dispatchers.io()) { val backupUid = uuidGenerator.random() @@ -554,6 +616,7 @@ class BackupManagerImpl( val floatingLayouts: MutableList = mutableListOf() val floatingButtons: MutableList = mutableListOf() + val groupMap: MutableMap = mutableMapOf() if (keyMapList != null) { val floatingButtonTriggerKeys = keyMapList @@ -571,6 +634,20 @@ class BackupManagerImpl( floatingButtons.add(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 + } + } } val backupContent = BackupContent( @@ -609,6 +686,7 @@ class BackupManagerImpl( .takeIf { it != PreferenceDefaults.VIBRATION_DURATION }, floatingLayouts = floatingLayouts.takeIf { it.isNotEmpty() }, floatingButtons = floatingButtons.takeIf { it.isNotEmpty() }, + groups = groupMap.values.toList(), ) val json = gson.toJson(backupContent) 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 5f1896e636..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 @@ -113,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, @@ -126,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, @@ -167,31 +167,31 @@ 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()) } } } @@ -275,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 } @@ -295,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 } @@ -312,7 +322,7 @@ class ChooseConstraintViewModel( response ?: return - _returnResult.emit(Constraint.ScreenOn) + _returnResult.emit(Constraint.ScreenOn()) } private suspend fun onSelectScreenOffConstraint() { @@ -323,7 +333,7 @@ class ChooseConstraintViewModel( response ?: return - _returnResult.emit(Constraint.ScreenOff) + _returnResult.emit(Constraint.ScreenOff()) } private suspend fun onSelectBluetoothConstraint(type: ConstraintId) { @@ -341,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") @@ -366,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 5b5c7548c6..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 @@ -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,16 +76,16 @@ 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 -> if (constraint.lens == CameraLens.FRONT) { @@ -116,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 { @@ -135,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/data/db/AppDatabase.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 1d6b4e1473..52d3b3b711 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 @@ -12,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 @@ -21,9 +22,11 @@ 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.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -40,12 +43,14 @@ 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), ], ) @TypeConverters( @@ -57,7 +62,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 = 15 + const val DATABASE_VERSION = 16 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -153,4 +158,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/GroupDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt new file mode 100644 index 0000000000..5401f87836 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt @@ -0,0 +1,57 @@ +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.GroupEntityWithSubGroups +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" + } + + @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) +} 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/GroupEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt new file mode 100644 index 0000000000..ae13d34187 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt @@ -0,0 +1,76 @@ +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 +import com.github.salomonbrys.kotson.byInt +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, + indices = [Index(value = [GroupDao.KEY_NAME], unique = true)], + 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?, + +) : 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" + + 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) + + GroupEntity(uid, name, constraintList, constraintMode, parentUid) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithSubGroups.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithSubGroups.kt new file mode 100644 index 0000000000..df226234be --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithSubGroups.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 GroupEntityWithSubGroups( + @Embedded + val group: GroupEntity, + + @Relation( + parentColumn = GroupDao.KEY_UID, + entityColumn = GroupDao.KEY_PARENT_UID, + ) + val subGroups: 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/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/repositories/GroupRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt new file mode 100644 index 0000000000..040095a000 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt @@ -0,0 +1,84 @@ +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.GroupEntityWithSubGroups +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 getGroupWithSubGroups(uid: String): Flow + suspend fun insert(groupEntity: GroupEntity) + suspend fun update(groupEntity: GroupEntity) + fun delete(uid: String) +} + +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 getGroupWithSubGroups(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) + } + } + } +} 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..89c6f6d83d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RepositoryUtils.kt @@ -0,0 +1,29 @@ +package io.github.sds100.keymapper.data.repositories + +import android.database.sqlite.SQLiteConstraintException + +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 (_: SQLiteConstraintException) { + // 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..ebfad89665 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,6 +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.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first @@ -47,9 +48,17 @@ class RoomKeyMapRepository( } } + 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) } @@ -65,7 +74,7 @@ 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) } @@ -77,7 +86,7 @@ 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) } @@ -87,7 +96,7 @@ class RoomKeyMapRepository( 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) { @@ -104,7 +113,7 @@ class RoomKeyMapRepository( 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) } @@ -114,7 +123,7 @@ class RoomKeyMapRepository( 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) } @@ -122,6 +131,16 @@ class RoomKeyMapRepository( } } + override fun moveToGroup(groupUid: String?, vararg uid: String) { + coroutineScope.launch { + for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { + keyMapDao.setKeyMapGroup(groupUid, *it) + } + + requestBackup() + } + } + private suspend fun migrateFingerprintMaps() = withContext(dispatchers.io()) { val entities = fingerprintMapDao.getAll().first() 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..11ba699cae --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt @@ -0,0 +1,42 @@ +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 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)) + }, + 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..496b62771b --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt @@ -0,0 +1,41 @@ +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?, +) + +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, + ) + } + + 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, + ) + } +} 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..da9ec9382f --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt @@ -0,0 +1,81 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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, +) { + Row(modifier = modifier) { + 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( + 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, + ) + } + } +} 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..9bf5d11e2a --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -0,0 +1,306 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.res.stringResource +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.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, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onFixConstraintClick: (Error) -> Unit = {}, + enabled: Boolean = true, +) { + FlowRow( + modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NewConstraintButton( + onClick = onNewConstraintClick, + showText = constraints.isEmpty(), + enabled = enabled, + ) + + for (constraint in constraints) { + when (constraint) { + is ComposeChipModel.Normal -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + ConstraintButton( + 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( + text = constraint.text, + onClick = { onFixConstraintClick(constraint.error) }, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, + ) + } + } + } + } +} + +@Composable +private fun NewConstraintButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + showText: Boolean = true, + enabled: Boolean, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier.height(28.dp), + 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.height(28.dp), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + ) + + 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.height(28.dp), + 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), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + ) + + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + ) + + 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()) + } + } +} + +@Preview +@Composable +private fun PreviewOneItem() { + KeyMapperTheme { + Surface { + GroupConstraintRow( + constraints = listOf( + ComposeChipModel.Normal( + id = "1", + text = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ), + ) + } + } +} + +@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.Error( + id = "2", + text = "Key Mapper not found", + error = Error.AppNotFound(Constants.PACKAGE_NAME), + ), + ), + ) + } + } +} 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..afb5d01095 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -0,0 +1,308 @@ +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.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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.res.stringResource +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, +) { + var viewAllState by rememberSaveable { mutableStateOf(false) } + + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + modifier + .verticalScroll(rememberScrollState()) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + maxLines = if (viewAllState) { + Int.MAX_VALUE + } else { + 2 + }, + overflow = FlowRowOverflow.expandOrCollapseIndicator( + expandIndicator = { + ViewAllButton( + onClick = { viewAllState = true }, + text = stringResource(R.string.home_new_view_all_groups_button), + enabled = enabled, + ) + }, + collapseIndicator = { + ViewAllButton( + onClick = { viewAllState = false }, + text = stringResource(R.string.home_new_hide_groups_button), + enabled = enabled, + ) + }, + minRowsToShowCollapse = 3, + ), + ) { + NewGroupButton( + onClick = onNewGroupClick, + text = stringResource(R.string.home_new_group_button), + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + enabled = enabled, + ) + + for (group in groups) { + GroupButton( + 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 -> {} + } + }, + ) + } + } +} + +@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.height(36.dp), + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, color = color), + color = Color.Transparent, + enabled = enabled, + ) { + Row( + modifier = Modifier.padding(vertical = 8.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 ViewAllButton( + 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.height(36.dp), + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, color), + color = Color.Transparent, + enabled = enabled, + ) { + AnimatedContent(text) { text -> + Text( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + 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.height(36.dp), + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), + enabled = enabled, + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@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, + ) + } + } +} + +@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 = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + ), + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupWithSubGroups.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupWithSubGroups.kt new file mode 100644 index 0000000000..407dc502b6 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupWithSubGroups.kt @@ -0,0 +1,6 @@ +package io.github.sds100.keymapper.groups + +data class GroupWithSubGroups( + val group: Group?, + val subGroups: List, +) 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..66f50f6623 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -0,0 +1,681 @@ +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, + isEditingGroupName = viewModel.isEditingGroupName, + 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(), + ) + + SelectionBottomSheet( + modifier = Modifier.onSizeChanged { size -> + keyMapListBottomPadding = + ((size.height.dp / 2) - 100.dp).coerceAtLeast(0.dp) + }, + enabled = selectionState.selectionCount > 0, + groups = selectionState.groups, + selectedKeyMapsEnabled = selectionState.selectedKeyMapsEnabled, + onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, + onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, + onExportClick = viewModel::onExportSelectedKeyMaps, + onDeleteClick = { showDeleteDialog = true }, + onMoveToGroupClick = viewModel::onMoveToGroupClick, + onNewGroupClick = viewModel::onNewGroupClick, + ) + } + }, + ) +} + +@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(), + ) + + 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(), + ) + }, + ) + } +} + +@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..45f3768f72 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 @@ -4,9 +4,6 @@ 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.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() } @@ -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..09cb6bef3a --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -0,0 +1,1106 @@ +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.horizontalScroll +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.rememberScrollState +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 }, + isEditingGroupName: Boolean = false, + 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 = { + AnimatedVisibility(!isEditingGroupName) { + 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)) } + var showDeleteGroupDialog by remember { mutableStateOf(false) } + + LaunchedEffect(state.groupName) { + newName = TextFieldValue(state.groupName) + showDeleteGroupDialog = false + error = null + } + + LaunchedEffect(isEditingGroupName) { + if (isEditingGroupName) { + newName = newName.copy(selection = TextRange(0, state.groupName.length)) + } + } + + if (showDeleteGroupDialog) { + DeleteGroupDialog( + groupName = state.groupName, + onDismissRequest = { showDeleteGroupDialog = false }, + onDeleteClick = onDeleteGroupClick, + ) + } + + ChildGroupAppBar( + modifier = modifier, + groupName = if (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 = isEditingGroupName, + subGroups = state.subGroups, + parentGroups = state.parentGroups, + onGroupClick = onGroupClick, + constraints = state.constraints, + constraintMode = state.constraintMode, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + onConstraintModeChanged = onConstraintModeChanged, + onFixConstraintClick = onFixConstraintClick, + actions = { + AnimatedVisibility(!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(8.dp) + .fillMaxWidth(), + groups = state.subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + ) + } + } +} + +@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, + 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() + } + } + + Column(horizontalAlignment = Alignment.End) { + // Text( + // modifier = Modifier.padding(horizontal = 8.dp), + // text = stringResource(R.string.home_group_constraints_title), + // style = MaterialTheme.typography.titleSmall, + // ) + + GroupConstraintRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + constraints = constraints, + onFixConstraintClick = onFixConstraintClick, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + enabled = !isEditingGroupName, + ) + + Spacer(Modifier.height(8.dp)) + + androidx.compose.animation.AnimatedVisibility(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 { + GroupRow( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + enabled = !isEditingGroupName, + ) + + val scrollState = rememberScrollState() + + LaunchedEffect(parentGroups) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + GroupBreadcrumbRow( + modifier = Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .padding(8.dp), + groups = parentGroups, + onGroupClick = onGroupClick, + ) + } + } + } +} + +@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), 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 + }, + ), + 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, + ) + }, + 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, + ), + ) + } + + 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(), + constraintMode = ConstraintMode.AND, + parentGroups = groupSampleList(), + ) + KeyMapperTheme { + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupDarkPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Short name", + subGroups = groupSampleList(), + constraints = emptyList(), + constraintMode = ConstraintMode.AND, + parentGroups = emptyList(), + ) + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupEditingPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Untitled group 23", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + constraintMode = ConstraintMode.AND, + parentGroups = emptyList(), + ) + + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme { + KeyMapAppBar( + state = state, + isEditingGroupName = true, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupEditingDarkPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Untitled group 23", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + constraintMode = ConstraintMode.AND, + parentGroups = emptyList(), + ) + + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar( + state = state, + isEditingGroupName = true, + ) + } +} + +@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, + ) + } +} + +@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(), + ) + 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(), + ) + 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..ee90efc752 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -0,0 +1,265 @@ +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.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, + groups: List, + enabled: Boolean, + selectedKeyMapsEnabled: SelectedKeyMapsEnabled, + onDuplicateClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {}, + onExportClick: () -> Unit = {}, + onEnabledKeyMapsChange: (Boolean) -> Unit = {}, + onNewGroupClick: () -> Unit = {}, + onMoveToGroupClick: (String) -> Unit = {}, +) { + Surface( + modifier = modifier + .widthIn(max = BottomSheetDefaults.SheetMaxWidth) + .fillMaxWidth() + .navigationBarsPadding(), + shadowElevation = 5.dp, + shape = BottomSheetDefaults.ExpandedShape, + tonalElevation = BottomSheetDefaults.Elevation, + color = BottomSheetDefaults.ContainerColor, + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .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, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + stringResource(R.string.home_move_to_group), + style = MaterialTheme.typography.labelLarge, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + GroupRow( + modifier = Modifier.fillMaxWidth(), + groups = groups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onMoveToGroupClick, + enabled = enabled, + ) + } + } +} + +@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(), + 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, + ), + ), + 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..33dddbb471 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,91 @@ 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, + parentGroups = parentGroupListItems, + ) + } + + 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..8f16384238 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt @@ -0,0 +1,31 @@ +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 subGroups: List, + val parentGroups: List, + + ) : KeyMapAppBarState() + + data class Selecting( + val selectionCount: Int, + val selectedKeyMapsEnabled: SelectedKeyMapsEnabled, + val isAllSelected: Boolean, + val groups: List, + ) : 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..0657a7d7dd 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,35 +3,68 @@ 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.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 @@ -41,24 +74,46 @@ 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" + + private const val HOME_GROUP_UID = "home_group" + } + + 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 +128,308 @@ 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 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 + var isEditingGroupName by mutableStateOf(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 homeGroupListItem = GroupListItemModel( + uid = HOME_GROUP_UID, + name = getString(R.string.home_groups_breadcrumb_home), + icon = null, + ) + + val groupListItems = + combine(keyMapGroupStateFlow, listKeyMaps.getGroups()) { keyMapGroup, groupList -> + val listItems = mutableListOf() + + // Only add the home group list item if the current group is not the home one. + if (keyMapGroup.group != null) { + listItems.add(homeGroupListItem) + } + + val filteredGroups = groupList + .filter { it.uid != keyMapGroup.group?.uid } + .map(::buildGroupListItem) + + listItems.addAll(filteredGroups) + + listItems + } + + val selectionAppBarState = combine( + multiSelectProvider.state.filterIsInstance(), + keyMapGroupStateFlow, + groupListItems, + ) { selectionState, keyMapGroup, groups -> + buildSelectingAppBarState( + keyMapGroup, + selectionState, + groups, + ) + } + + val groupAppBarState = combine( + keyMapGroupStateFlow, + warnings, + pauseKeyMaps.isPaused, + listKeyMaps.constraintErrorSnapshot, + ) { keyMapGroup, warnings, isPaused, constraintErrorSnapshot -> + buildGroupAppBarState( + keyMapGroup, + warnings, + isPaused, + constraintErrorSnapshot, + ) + } + + @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) + } + } - _state.value = listItemContentList.mapData { contentList -> - contentList.map { content -> - val isSelected = if (selectionState is SelectionState.Selecting) { - selectionState.selectedIds.contains(content.uid) - } else { - false - } + coroutineScope.launch { + backupRestore.onAutomaticBackupResult.collectLatest { result -> + onAutomaticBackupResult(result) + } + } + } - KeyMapListItemModel(isSelected, content) + private fun buildSelectingAppBarState( + keyMapGroup: KeyMapGroup, + selectionState: SelectionState.Selecting, + groupListItems: List, + ): KeyMapAppBarState.Selecting { + var selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = null + val keyMaps = keyMapGroup.keyMaps.dataOrNull() ?: emptyList() + + 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, + ) + } + + private fun buildGroupAppBarState( + keyMapGroup: KeyMapGroup, + warnings: List, + isPaused: Boolean, + constraintErrorSnapshot: ConstraintErrorSnapshot, + ): KeyMapAppBarState { + val subGroupListItems = keyMapGroup.subGroups.map { group -> + buildGroupListItem(group) + } + + val parentGroupListItems = keyMapGroup.parents.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, + subGroups = subGroupListItems, + parentGroups = parentGroupListItems, + ) + } + } + + 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,7 +441,7 @@ class KeyMapListViewModel( } } else { coroutineScope.launch { - navigate("config_key_map", NavDestination.ConfigKeyMap(uid)) + navigate("config_key_map", NavDestination.ConfigKeyMap.Open(uid)) } } } @@ -169,18 +465,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 +484,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 +534,304 @@ 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 onMoveToGroupClick(groupUid: String) { + val selectionState = multiSelectProvider.state.value + + if (selectionState !is SelectionState.Selecting) return + val selectedIds = selectionState.selectedIds.toTypedArray() + + if (groupUid == HOME_GROUP_UID) { + listKeyMaps.moveKeyMapsToGroup(null, *selectedIds) + } else { + listKeyMaps.moveKeyMapsToGroup(groupUid, *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 + } + + fun onBackClick(): Boolean { + when { + multiSelectProvider.state.value is SelectionState.Selecting -> { + multiSelectProvider.stopSelecting() + return true + } + + state.value.appBarState is KeyMapAppBarState.ChildGroup -> { + if (isEditingGroupName) { + if (isNewGroup) { + coroutineScope.launch { + listKeyMaps.deleteGroup() + } + } else { + isEditingGroupName = false + } + } else { + coroutineScope.launch { + listKeyMaps.popGroup() + } + } + + isEditingGroupName = false + return true + } + + else -> { + return false + } + } + } + + suspend fun onRenameGroupClick(name: String): Boolean { + return listKeyMaps.renameGroup(name).also { success -> + if (success) { + isEditingGroupName = false + } + } + } + + fun onEditGroupNameClick() { + isEditingGroupName = true + } + + fun onGroupClick(uid: String?) { + coroutineScope.launch { + listKeyMaps.openGroup(uid) + } + } + + fun onDeleteGroupClick() { + coroutineScope.launch { + listKeyMaps.deleteGroup() + } + } + + fun onNewGroupClick() { + coroutineScope.launch { + multiSelectProvider.stopSelecting() + listKeyMaps.newGroup() + isNewGroup = true + isEditingGroupName = 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..dfbdaece56 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 @@ -11,6 +11,8 @@ 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 +22,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..999fc37be2 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,19 +1,38 @@ 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.GroupWithSubGroups 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.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext /** @@ -21,27 +40,230 @@ import kotlinx.coroutines.withContext */ 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 groupUid = MutableStateFlow(null) + private val parentGroupUids = MutableStateFlow>(emptyList()) - override val keyMapList: Flow>> = channelFlow { + @OptIn(ExperimentalCoroutinesApi::class) + private val group: Flow = groupUid.flatMapLatest { groupUid -> + if (groupUid == null) { + groupRepository.getGroupsByParent(null).map { subGroupEntities -> + val subGroups = subGroupEntities.map(GroupEntityMapper::fromEntity) + GroupWithSubGroups(group = null, subGroups = subGroups) + } + } else { + groupRepository.getGroupWithSubGroups(groupUid).map { groupWithSubGroups -> + val group = GroupEntityMapper.fromEntity(groupWithSubGroups.group) + val subGroups = + groupWithSubGroups.subGroups.map(GroupEntityMapper::fromEntity) + + GroupWithSubGroups(group, subGroups) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val parentGroups: Flow> = + parentGroupUids + .flatMapLatest { uids -> + groupRepository.getGroups(*uids.toTypedArray()) + .map { groups -> + // The repository returns the objects unordered so order them by the + // original UID list again. + val mapped = groups.associateBy { it.uid } + uids.map { GroupEntityMapper.fromEntity(mapped[it]!!) } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override val keyMapGroup: Flow = channelFlow { + combine(group, parentGroups) { group, parentGroups -> + KeyMapGroup( + group = group.group, + subGroups = group.subGroups, + keyMaps = State.Loading, + parents = parentGroups, + ) + }.onEach { send(it) } + .flatMapLatest { keyMapGroup -> + getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } + }.collect { + send(it) + } + } + + override fun getGroups(): Flow> { + return groupRepository.groups.map { list -> list.map(GroupEntityMapper::fromEntity) } + } + + override suspend fun newGroup() { + val defaultName = resourceProvider.getString(R.string.default_group_name) + val group = GroupEntity(parentUid = groupUid.value, name = defaultName) + + ensureUniqueName(group) { + groupRepository.insert(it) + } + + groupUid.update { group.uid } + parentGroupUids.update { it.plus(group.uid) } + } + + override suspend fun deleteGroup() { + groupUid.value?.also { groupUid -> + val group = groupRepository.getGroup(groupUid) ?: return + + this.groupUid.value = group.parentUid + this.parentGroupUids.update { list -> + list.takeWhile { it != group.uid } + } + groupRepository.delete(groupUid) + } + } + + override suspend fun renameGroup(name: String): Boolean { + if (name.isBlank()) { + return true + } + + groupUid.value?.also { groupUid -> + var entity = groupRepository.getGroup(groupUid) ?: return true + + entity = entity.copy(name = name.trim()) + + try { + groupRepository.update(entity) + } catch (_: SQLiteConstraintException) { + return false + } + } + + return true + } + + private suspend fun ensureUniqueName( + group: GroupEntity, + block: suspend (entity: GroupEntity) -> Unit, + ): GroupEntity { + return RepositoryUtils.saveUniqueName( + entity = group, + saveBlock = block, + 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. + groupUid.update { null } + parentGroupUids.update { emptyList() } + } else { + // Check if the group exists. + val group = groupRepository.getGroup(uid) ?: return + groupUid.update { group.uid } + + parentGroupUids.update { list -> + if (list.contains(group.uid)) { + list.takeWhile { it != uid }.plus(group.uid) + } else { + list.plus(group.uid) + } + } + } + } + + override suspend fun popGroup() { + val currentGroupUid = groupUid.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) { + groupUid.value = null + parentGroupUids.update { emptyList() } + } else { + // Check if the group exists. + val group = groupRepository.getGroup(currentGroup.parentUid) ?: return + groupUid.update { group.uid } + parentGroupUids.update { list -> list.dropLast(1) } + } + } + + override suspend fun addGroupConstraint(constraint: Constraint) { + groupUid.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) { + groupUid.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) { + groupUid.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) + } + + 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 +308,18 @@ 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(): Flow> + fun moveKeyMapsToGroup(groupUid: String?, 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/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 c56d83205e..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 @@ -68,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, @@ -107,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 = @@ -205,7 +205,7 @@ abstract class BaseAccessibilityServiceController( }.launchIn(coroutineScope) } - pauseMappingsUseCase.isPaused.distinctUntilChanged().onEach { + pauseKeyMapsUseCase.isPaused.distinctUntilChanged().onEach { keyMapController.reset() triggerKeyMapFromOtherAppsController.reset() }.launchIn(coroutineScope) @@ -235,7 +235,7 @@ abstract class BaseAccessibilityServiceController( }.launchIn(coroutineScope) combine( - pauseMappingsUseCase.isPaused, + pauseKeyMapsUseCase.isPaused, detectKeyMapsUseCase.allKeyMapList, ) { isPaused, keyMaps -> val enableAccessibilityVolumeStream: Boolean @@ -243,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 } } } 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 f2e9d3deb5..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 @@ -139,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), @@ -151,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), @@ -165,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), @@ -214,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/home/ChooseAppStoreModel.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/ChooseAppStoreModel.kt similarity index 81% rename from app/src/main/java/io/github/sds100/keymapper/home/ChooseAppStoreModel.kt rename to app/src/main/java/io/github/sds100/keymapper/util/ui/ChooseAppStoreModel.kt index 3a5c0d588e..5d724df2ab 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/ChooseAppStoreModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/ChooseAppStoreModel.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.home +package io.github.sds100.keymapper.util.ui /** * Created by sds100 on 24/07/20. diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt index b709f7ffd9..4e3ede84f8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt @@ -103,8 +103,13 @@ sealed class NavDestination { 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/strings.xml b/app/src/main/res/values/strings.xml index e450ad6c11..e219f3d8d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1317,6 +1317,7 @@ Cancel Hide floating layouts You can find floating buttons in the Advanced Triggers button when creating a trigger. + Floating buttons Menu Sort More @@ -1324,6 +1325,7 @@ Select all Deselect all Stop selecting + Go up a group Paused 1 warning @@ -1331,6 +1333,7 @@ Running Settings + Delete group About Export all Import @@ -1358,6 +1361,7 @@ Enabled Disabled Mixed + Move to group Delete 1 key map @@ -1368,6 +1372,14 @@ Cancel Save to files + New group + New subgroup + View all + Hide + Group constraints + New constraint + Delete group constraint + Remove Edit @@ -1431,4 +1443,15 @@ 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 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..48a02abec9 100644 --- a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt @@ -12,6 +12,7 @@ import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.EntityExtra import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.repositories.FakePreferenceRepository +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 @@ -43,6 +44,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 @@ -109,6 +111,9 @@ class BackupManagerTest { uuidGenerator = mockUuidGenerator, floatingButtonRepository = mock {}, floatingLayoutRepository = mock {}, + groupRepository = mock { + on { getAllGroups() } doReturn MutableStateFlow(emptyList()) + }, ) parser = JsonParser() 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..1dda70b7f4 --- /dev/null +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ProcessKeyMapGroupsForDetectionTest.kt @@ -0,0 +1,295 @@ +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, + ) + } +} 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) {