From df00ca5dd79f4f53558ba5459af18da354f8bc12 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 17:30:05 -0600 Subject: [PATCH 01/94] #320 create GroupEntity and database migration --- .../15.json | 390 ++++++++++++++++++ .../sds100/keymapper/data/db/AppDatabase.kt | 12 +- .../sds100/keymapper/data/db/dao/GroupDao.kt | 15 + .../sds100/keymapper/data/db/dao/KeyMapDao.kt | 2 +- .../data/entities/ConstraintEntity.kt | 5 +- .../keymapper/data/entities/GroupEntity.kt | 75 ++++ .../keymapper/data/entities/KeyMapEntity.kt | 23 +- .../data/migration/AutoMigration14To15.kt | 7 + 8 files changed, 517 insertions(+), 12 deletions(-) create mode 100644 app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json new file mode 100644 index 0000000000..4ae70562fa --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json @@ -0,0 +1,390 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "2139445f26879f72f7a5a9c441900686", + "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, 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 + } + ], + "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": "constraints", + "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, '2139445f26879f72f7a5a9c441900686')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 5260652290..2ae542f973 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.data.db import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @@ -11,6 +12,7 @@ import io.github.sds100.keymapper.data.db.AppDatabase.Companion.DATABASE_VERSION import io.github.sds100.keymapper.data.db.dao.FingerprintMapDao import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao import io.github.sds100.keymapper.data.db.dao.FloatingLayoutDao +import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.db.dao.LogEntryDao import io.github.sds100.keymapper.data.db.typeconverter.ActionListTypeConverter @@ -20,8 +22,10 @@ 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.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -38,9 +42,12 @@ 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 = [ + AutoMigration(from = 14, to = 15, spec = AutoMigration14To15::class), + ], ) @TypeConverters( ActionListTypeConverter::class, @@ -51,7 +58,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 14 + const val DATABASE_VERSION = 15 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -141,4 +148,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..37d50a56c6 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.data.db.dao + +import androidx.room.Dao + +@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" + } +} 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..2c662566fa 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)") 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..de3f8fe31c 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,21 +1,24 @@ package io.github.sds100.keymapper.data.entities +import android.os.Parcelable import com.github.salomonbrys.kotson.byArray import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize /** * Created by sds100 on 17/03/2020. */ +@Parcelize data class ConstraintEntity( @SerializedName(NAME_TYPE) val type: String, @SerializedName(NAME_EXTRAS) val extras: List, -) { +) : Parcelable { constructor(type: String, vararg extra: EntityExtra) : this(type, extra.toList()) 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..8a9697406a --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt @@ -0,0 +1,75 @@ +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.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.entities.KeyMapEntity.Companion.NAME_CONSTRAINT_LIST +import kotlinx.parcelize.Parcelize + +@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, + + @ColumnInfo(name = GroupDao.KEY_NAME) + @SerializedName(NAME_NAME) + val name: String, + + @ColumnInfo(name = GroupDao.KEY_CONSTRAINTS) + @SerializedName(NAME_CONSTRAINTS) + val constraints: 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_CONSTRAINT_LIST) + val constraintList = + it.context.deserialize>(constraintListJsonArray) + + val constraintMode by it.json.byInt(KeyMapEntity.NAME_CONSTRAINT_MODE) + val parentUid by it.json.byString(NAME_PARENT_UID) + + GroupEntity(uid, name, constraintList, constraintMode, parentUid) + } + } +} 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..85667d517b 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 @@ -2,16 +2,17 @@ package io.github.sds100.keymapper.data.entities 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.byBool import com.github.salomonbrys.kotson.byInt -import com.github.salomonbrys.kotson.byNullableString 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 java.util.UUID @@ -22,6 +23,14 @@ 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, + ), + ], ) data class KeyMapEntity( @SerializedName(NAME_ID) @@ -51,10 +60,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,6 +67,10 @@ data class KeyMapEntity( @SerializedName(NAME_UID) @ColumnInfo(name = KeyMapDao.KEY_UID) val uid: String = UUID.randomUUID().toString(), + + @SerializedName(GROUP_UID) + @ColumnInfo(name = KeyMapDao.KEY_GROUP_UID) + val groupUid: String? = null, ) { companion object { @@ -72,9 +81,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 GROUP_UID = "group_uid" val DESERIALIZER = jsonDeserializer { val actionListJsonArray by it.json.byArray(NAME_ACTION_LIST) @@ -89,7 +98,6 @@ 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() } @@ -100,7 +108,6 @@ data class KeyMapEntity( constraintList, constraintMode, flags, - folderName, isEnabled, uid, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt new file mode 100644 index 0000000000..38259c2bf3 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.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 AutoMigration14To15 : AutoMigrationSpec From ea860d546fa7c0639edcd2dc27f4069974061437 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 17:45:09 -0600 Subject: [PATCH 02/94] chore: bump version to 3.0 Beta 3 --- CHANGELOG.md | 2 +- app/version.properties | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13dc72f83a..46339c06db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) -#### TO BE RELEASED +#### 27 March 2025 _See the changes from previous 3.0 Beta releases as well._ diff --git a/app/version.properties b/app/version.properties index d53b96ecc2..f3894e9a39 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=3.0.0-beta.2 -VERSION_CODE=83 +VERSION_NAME=3.0.0-beta.3 +VERSION_CODE=84 VERSION_NUM=0 \ No newline at end of file From fcf562b0c1bdb6390020108495bc271756c76aae Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 18:13:10 -0600 Subject: [PATCH 03/94] update whats new --- app/src/main/assets/whats-new.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/assets/whats-new.txt b/app/src/main/assets/whats-new.txt index 0b7e9f81fc..7450bdcdbf 100644 --- a/app/src/main/assets/whats-new.txt +++ b/app/src/main/assets/whats-new.txt @@ -1,8 +1,9 @@ Key Mapper 3.0 is here! 🎉 🫧 This release introduces Floating Buttons: you can create custom on-screen buttons to trigger key maps. +🔦 You can now change the flashlight brightness. Tip: use the constraint for when the flashlight is showing to remap your volume buttons to change the brightness. ❤️ There are also tonnes of improvements to make your key mapping experience more enjoyable. -👀 Grouping key maps into folders with shared constraints are in the pipeline. +👀 Grouping key maps into folders with shared constraints are a work in progress. This will be free for everyone! See all the changes at http://changelog.keymapper.club. \ No newline at end of file From ae3ef905cc2493cec69e16373885e19122ec5bc0 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 19:47:20 -0600 Subject: [PATCH 04/94] #320 WIP: create data classes for groups --- .../github/sds100/keymapper/ServiceLocator.kt | 16 ++++ .../github/sds100/keymapper/actions/Action.kt | 2 +- .../sds100/keymapper/data/db/dao/GroupDao.kt | 20 +++++ .../sds100/keymapper/data/db/dao/KeyMapDao.kt | 4 + .../keymapper/data/entities/GroupEntity.kt | 2 +- .../data/entities/GroupEntityWithSubGroups.kt | 16 ++++ .../data/entities/KeyMapEntitiesWithGroup.kt | 20 +++++ .../keymapper/data/entities/KeyMapEntity.kt | 5 +- .../data/repositories/GroupRepository.kt | 39 +++++++++ .../data/repositories/RoomKeyMapRepository.kt | 9 ++ .../github/sds100/keymapper/groups/Group.kt | 41 +++++++++ .../keymapper/groups/GroupWithSubGroups.kt | 6 ++ .../keymapper/groups/SubGroupListModel.kt | 5 ++ .../sds100/keymapper/home/HomeScreen.kt | 1 + .../keymapper/mappings/keymaps/KeyMap.kt | 6 +- .../keymapper/mappings/keymaps/KeyMapGroup.kt | 10 +++ .../mappings/keymaps/KeyMapListScreen.kt | 4 +- .../mappings/keymaps/KeyMapListViewModel.kt | 30 ++++++- .../mappings/keymaps/KeyMapRepository.kt | 2 + .../mappings/keymaps/ListKeyMapsUseCase.kt | 85 +++++++++++++++++-- .../io/github/sds100/keymapper/util/Inject.kt | 2 + 21 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithSubGroups.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/Group.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/GroupWithSubGroups.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/SubGroupListModel.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapGroup.kt 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..9f8ee1366a 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 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/data/db/dao/GroupDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt index 37d50a56c6..da0f498201 100644 --- 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 @@ -1,6 +1,11 @@ package io.github.sds100.keymapper.data.db.dao import androidx.room.Dao +import androidx.room.Query +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 { @@ -12,4 +17,19 @@ interface GroupDao { const val KEY_CONSTRAINT_MODE = "constraint_mode" const val KEY_PARENT_UID = "parent_uid" } + + @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 = (: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 = (:uid)") + fun getGroupsByParent(uid: String?): Flow> } 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 2c662566fa..ae77eea382 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 @@ -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() 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 index 8a9697406a..cd9a1aa907 100644 --- 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 @@ -40,7 +40,7 @@ data class GroupEntity( @ColumnInfo(name = GroupDao.KEY_CONSTRAINTS) @SerializedName(NAME_CONSTRAINTS) - val constraints: List = emptyList(), + val constraintList: List = emptyList(), @ColumnInfo(name = GroupDao.KEY_CONSTRAINT_MODE) @SerializedName(NAME_CONSTRAINT_MODE) 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 85667d517b..2215f28a07 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,5 +1,6 @@ package io.github.sds100.keymapper.data.entities +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @@ -14,6 +15,7 @@ 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 /** @@ -32,6 +34,7 @@ import java.util.UUID ), ], ) +@Parcelize data class KeyMapEntity( @SerializedName(NAME_ID) @PrimaryKey(autoGenerate = true) @@ -71,7 +74,7 @@ data class KeyMapEntity( @SerializedName(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. 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..96e272385e --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt @@ -0,0 +1,39 @@ +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 + +interface GroupRepository { + fun getKeyMapsByGroup(groupUid: String): Flow + suspend fun getGroup(uid: String): GroupEntity? + fun getGroupsByParent(uid: String?): Flow> + fun getGroupWithSubGroups(uid: String): Flow +} + +class RoomGroupRepository( + private val dao: GroupDao, + private val coroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), +) : GroupRepository { + override fun getKeyMapsByGroup(groupUid: String): Flow { + return dao.getKeyMapsByGroup(groupUid) + } + + override suspend fun getGroup(uid: String): GroupEntity? { + return dao.getById(uid) + } + + override fun getGroupsByParent(uid: String?): Flow> { + return dao.getGroupsByParent(uid) + } + + override fun getGroupWithSubGroups(uid: String): Flow { + return dao.getGroupWithSubGroups(uid) + } +} 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..23556328ce 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,6 +48,14 @@ 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 { 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/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/groups/SubGroupListModel.kt b/app/src/main/java/io/github/sds100/keymapper/groups/SubGroupListModel.kt new file mode 100644 index 0000000000..b8859f6444 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/SubGroupListModel.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.groups + +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo + +data class SubGroupListModel(val uid: String, val name: String, val icon: ComposeIconInfo? = null) 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..9faa890129 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 @@ -276,6 +276,7 @@ fun HomeScreen( snackBarState = snackbarState, navBarItems = navBarItems, topAppBar = { + // TODO use different app bars for each screen. HomeAppBar( scrollBehavior = scrollBehavior, homeState = homeState, 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..87a0dc9704 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 @@ -109,7 +109,7 @@ object KeyMapEntityMapper { 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() @@ -127,7 +127,7 @@ object KeyMapEntityMapper { } fun toEntity(keyMap: KeyMap, dbId: Long): KeyMapEntity { - val actionEntityList = KeymapActionEntityMapper.toEntity(keyMap) + val actionEntityList = ActionEntityMapper.toEntity(keyMap) return KeyMapEntity( id = dbId, 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..6fef4dfc03 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapGroup.kt @@ -0,0 +1,10 @@ +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 keyMaps: State>, +) 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..dedb954cb3 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 @@ -75,13 +75,13 @@ fun KeyMapListScreen( viewModel: KeyMapListViewModel, lazyListState: LazyListState, ) { - val listItems by viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val isSelectable by viewModel.isSelectable.collectAsStateWithLifecycle() KeyMapListScreen( modifier = modifier, lazyListState = lazyListState, - listItems = listItems, + listItems = state.listItems, footerText = if (isSelectable) { null } else { 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..e9626a5d21 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,6 +3,8 @@ 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.constraints.ConstraintMode +import io.github.sds100.keymapper.groups.SubGroupListModel 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 @@ -23,6 +25,7 @@ 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.ComposeChipModel import io.github.sds100.keymapper.util.ui.navigate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -50,7 +53,7 @@ class KeyMapListViewModel( private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) - private val _state = MutableStateFlow>>(State.Loading) + private val _state = MutableStateFlow(KeyMapListState.Root()) val state = _state.asStateFlow() var showFabText: Boolean by mutableStateOf(true) @@ -121,7 +124,7 @@ class KeyMapListViewModel( showFabText = listItemContentList.dataOrNull()?.isEmpty() ?: true - _state.value = listItemContentList.mapData { contentList -> + val listItems = listItemContentList.mapData { contentList -> contentList.map { content -> val isSelected = if (selectionState is SelectionState.Selecting) { selectionState.selectedIds.contains(content.uid) @@ -132,6 +135,8 @@ class KeyMapListViewModel( KeyMapListItemModel(isSelected, content) } } + + _state.value = KeyMapListState.Root(listItems = listItems) } } } @@ -171,7 +176,7 @@ class KeyMapListViewModel( fun selectAll() { coroutineScope.launch { - state.value.apply { + state.value.listItems.apply { if (this is State.Data) { multiSelectProvider.select( *this.data.map { it.uid }.toTypedArray(), @@ -251,3 +256,22 @@ class KeyMapListViewModel( listKeyMaps.neverShowDpadImeSetupError() } } + +sealed class KeyMapListState { + abstract val subGroups: List + abstract val listItems: State> + + data class Root( + override val subGroups: List = emptyList(), + override val listItems: State> = State.Loading, + ) : KeyMapListState() + + data class Child( + val groupName: String, + val constraints: List, + val constraintMode: ConstraintMode, + override val subGroups: List, + override val listItems: State>, + + ) : KeyMapListState() +} 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..2398adb124 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? 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..41eb435d6f 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 @@ -4,16 +4,25 @@ 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.data.repositories.FloatingButtonRepository +import io.github.sds100.keymapper.data.repositories.GroupRepository +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 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,6 +30,7 @@ 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, @@ -28,20 +38,75 @@ class ListKeyMapsUseCaseImpl( ) : ListKeyMapsUseCase, DisplayKeyMapUseCase by displayKeyMapUseCase { - override val keyMapList: Flow>> = channelFlow { + private val groupUid = MutableStateFlow(null) + + override suspend fun openGroup(uid: String) { + // Check if the group exists. + val group = groupRepository.getGroup(uid) ?: return + groupUid.update { 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 + } else { + // Check if the group exists. + val group = groupRepository.getGroup(currentGroup.parentUid) ?: return + groupUid.update { group.uid } + } + } + + @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) + override val keyMapGroup: Flow = channelFlow { + group + .map { group -> + KeyMapGroup( + group = group.group, + subGroups = group.subGroups, + keyMaps = State.Loading, + ) + } + .onEach { send(it) } + .flatMapLatest { keyMapGroup -> + getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } + } + } + + 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) { @@ -54,6 +119,8 @@ class ListKeyMapsUseCaseImpl( } } + override val keyMapList: Flow>> = getKeyMapsByGroup(null) + override fun deleteKeyMap(vararg uid: String) { keyMapRepository.delete(*uid) } @@ -86,8 +153,12 @@ class ListKeyMapsUseCaseImpl( } interface ListKeyMapsUseCase : DisplayKeyMapUseCase { + val keyMapGroup: Flow val keyMapList: Flow>> + suspend fun openGroup(uid: String) + suspend fun popGroup() + fun deleteKeyMap(vararg uid: String) fun enableKeyMap(vararg uid: String) fun disableKeyMap(vararg uid: String) diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index e42bb0b5a6..133f25be7b 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 @@ -138,6 +138,7 @@ object Inject { UseCases.configKeyMap(ctx), ListKeyMapsUseCaseImpl( ServiceLocator.roomKeyMapRepository(ctx), + ServiceLocator.groupRepository(ctx), ServiceLocator.floatingButtonRepository(ctx), ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), @@ -150,6 +151,7 @@ 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), From 68e8f88c946e48b71434ffdae9e40446b06e4859 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 27 Mar 2025 23:24:25 -0600 Subject: [PATCH 05/94] #320 build key map group UI state in view model --- .../sds100/keymapper/home/HomeViewModel.kt | 3 +- .../keymaps/CreateKeyMapShortcutScreen.kt | 4 +- .../keymaps/CreateKeyMapShortcutViewModel.kt | 83 ++++++++--- .../mappings/keymaps/KeyMapListItemCreator.kt | 12 +- .../mappings/keymaps/KeyMapListState.kt | 26 ++++ .../mappings/keymaps/KeyMapListViewModel.kt | 136 +++++++++++------- .../mappings/keymaps/ListKeyMapsUseCase.kt | 5 +- 7 files changed, 188 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListState.kt 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..3474597f53 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 @@ -58,6 +58,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -186,7 +187,7 @@ class HomeViewModel( multiSelectProvider.state, warnings, showAlertsUseCase.areKeyMapsPaused, - listKeyMaps.keyMapList.filterIsInstance>>(), + listKeyMaps.keyMapGroup.map { it.keyMaps }.filterIsInstance>>(), listFloatingLayoutsViewModel.state, ) { selectionState, warnings, isPaused, keyMaps, floatingLayoutsState -> 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..40f2dd3b00 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 -> 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..ca5313c64b 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,8 +9,12 @@ 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.groups.SubGroupListModel 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 @@ -38,7 +42,7 @@ class CreateKeyMapShortcutViewModel( private val actionUiHelper = ActionUiHelper(listKeyMaps, resourceProvider) private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) - private val _state = MutableStateFlow>>(State.Loading) + private val _state = MutableStateFlow(KeyMapListState.Root()) val state = _state.asStateFlow() private val _returnIntentResult = MutableSharedFlow() @@ -50,35 +54,76 @@ 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 -> + SubGroupListModel( + uid = group.uid, + name = group.name, + icon = null, // TODO show icon depending on constraints + ) + } + + if (keyMapGroup.group == null) { + return KeyMapListState.Root( + subGroups = subGroupListItems, + listItems = listItemsState, + ) + } else { + return KeyMapListState.Child( + groupName = keyMapGroup.group.name, + constraints = listItemCreator.buildConstraintChipList( + keyMapGroup.group.constraintState, + constraintErrorSnapshot, + ), + constraintMode = keyMapGroup.group.constraintState.mode, + subGroups = subGroupListItems, + listItems = 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/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 85fa04a7d5..735b3b252e 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) 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..108d2b0ace --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListState.kt @@ -0,0 +1,26 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.groups.SubGroupListModel +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel + +sealed class KeyMapListState { + abstract val subGroups: List + abstract val listItems: State> + + data class Root( + override val subGroups: List = emptyList(), + override val listItems: State> = State.Loading, + ) : KeyMapListState() + + data class Child( + val groupName: String, + val constraints: List, + val constraintMode: ConstraintMode, + override val subGroups: List, + override val listItems: State>, + + ) : KeyMapListState() +} 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 e9626a5d21..10ad8d99b5 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,12 +3,14 @@ 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.constraints.ConstraintMode +import io.github.sds100.keymapper.actions.ActionErrorSnapshot +import io.github.sds100.keymapper.constraints.ConstraintErrorSnapshot import io.github.sds100.keymapper.groups.SubGroupListModel 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.system.permissions.Permission import io.github.sds100.keymapper.util.Error @@ -25,7 +27,6 @@ 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.ComposeChipModel import io.github.sds100.keymapper.util.ui.navigate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -76,71 +77,123 @@ 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, + ), + ) + init { - val keyMapListFlow = combine( - listKeyMaps.keyMapList, + val keyMapGroupFlow = combine( + keyMapGroupStateFlow, sortKeyMaps.observeKeyMapsSorter(), - ) { keyMapList, sorter -> - keyMapList.mapData { list -> list.sortedWith(sorter) } + ) { keyMapGroup, sorter -> + keyMapGroup.copy( + keyMaps = keyMapGroup.keyMaps.mapData { list -> list.sortedWith(sorter) }, + ) }.flowOn(Dispatchers.Default) - val listItemContentFlow = + val listStateFlow = combine( - keyMapListFlow, + keyMapGroupFlow, 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 = ::buildState, + ).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) { combine( - listItemContentFlow, + listStateFlow, multiSelectProvider.state, - ) { keymapListState, selectionState -> - Pair(keymapListState, selectionState) - }.collectLatest { (listItemContentList, selectionState) -> + ) { listState, selectionState -> + Pair(listState, selectionState) + }.collectLatest { (listState, selectionState) -> // Stop selecting when there are no key maps - listItemContentList.ifIsData { list -> + listState.listItems.ifIsData { list -> if (list.isEmpty()) { multiSelectProvider.stopSelecting() } } - showFabText = listItemContentList.dataOrNull()?.isEmpty() ?: true + showFabText = listState.listItems.dataOrNull()?.isEmpty() ?: true - val listItems = listItemContentList.mapData { contentList -> - contentList.map { content -> + val listItemsWithSelection = listState.listItems.mapData { listItems -> + listItems.map { item -> val isSelected = if (selectionState is SelectionState.Selecting) { - selectionState.selectedIds.contains(content.uid) + selectionState.selectedIds.contains(item.uid) } else { false } - KeyMapListItemModel(isSelected, content) + item.copy(isSelected = isSelected) } } - _state.value = KeyMapListState.Root(listItems = listItems) + _state.value = when (listState) { + is KeyMapListState.Root -> listState.copy(listItems = listItemsWithSelection) + is KeyMapListState.Child -> listState.copy(listItems = listItemsWithSelection) + } } } } + 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 -> + SubGroupListModel( + uid = group.uid, + name = group.name, + icon = null, // TODO show icon depending on constraints + ) + } + + if (keyMapGroup.group == null) { + return KeyMapListState.Root( + subGroups = subGroupListItems, + listItems = listItemsState, + ) + } else { + return KeyMapListState.Child( + groupName = keyMapGroup.group.name, + constraints = listItemCreator.buildConstraintChipList( + keyMapGroup.group.constraintState, + constraintErrorSnapshot, + ), + constraintMode = keyMapGroup.group.constraintState.mode, + subGroups = subGroupListItems, + listItems = listItemsState, + ) + } + } + fun onKeyMapCardClick(uid: String) { if (multiSelectProvider.state.value is SelectionState.Selecting) { multiSelectProvider.toggleSelection(uid) @@ -256,22 +309,3 @@ class KeyMapListViewModel( listKeyMaps.neverShowDpadImeSetupError() } } - -sealed class KeyMapListState { - abstract val subGroups: List - abstract val listItems: State> - - data class Root( - override val subGroups: List = emptyList(), - override val listItems: State> = State.Loading, - ) : KeyMapListState() - - data class Child( - val groupName: String, - val constraints: List, - val constraintMode: ConstraintMode, - override val subGroups: List, - override val listItems: State>, - - ) : KeyMapListState() -} 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 41eb435d6f..42bb2b8f51 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 @@ -91,6 +91,8 @@ class ListKeyMapsUseCaseImpl( .onEach { send(it) } .flatMapLatest { keyMapGroup -> getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } + }.collect { + send(it) } } @@ -119,8 +121,6 @@ class ListKeyMapsUseCaseImpl( } } - override val keyMapList: Flow>> = getKeyMapsByGroup(null) - override fun deleteKeyMap(vararg uid: String) { keyMapRepository.delete(*uid) } @@ -154,7 +154,6 @@ class ListKeyMapsUseCaseImpl( interface ListKeyMapsUseCase : DisplayKeyMapUseCase { val keyMapGroup: Flow - val keyMapList: Flow>> suspend fun openGroup(uid: String) suspend fun popGroup() From ebceca04513a0fa5791403911b77948eaf1b6c9e Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 00:55:10 -0600 Subject: [PATCH 06/94] #320 WIP: refactor home screen completely --- .../io/github/sds100/keymapper/UseCases.kt | 4 +- .../actions/ConfigActionsViewModel.kt | 2 +- .../keymapper/home/DeleteKeyMapsDialog.kt | 49 + .../keymapper/home/HomeKeyMapListScreen.kt | 459 ++++++ .../sds100/keymapper/home/HomeScreen.kt | 1240 ++--------------- .../sds100/keymapper/home/HomeViewModel.kt | 346 +---- .../sds100/keymapper/home/HomeWarningList.kt | 75 + .../sds100/keymapper/home/ImportDialog.kt | 54 + .../sds100/keymapper/home/KeyMapAppBar.kt | 364 +++++ .../keymapper/home/SelectionBottomSheet.kt | 182 +++ .../home/ShowHomeScreenAlertsUseCase.kt | 8 +- ...pingsUseCase.kt => PauseKeyMapsUseCase.kt} | 6 +- .../keymaps/CreateKeyMapShortcutScreen.kt | 2 +- .../mappings/keymaps/KeyMapAppBarState.kt | 29 + .../mappings/keymaps/KeyMapListScreen.kt | 183 +-- .../mappings/keymaps/KeyMapListState.kt | 25 +- .../mappings/keymaps/KeyMapListViewModel.kt | 489 +++++-- .../Android11BugWorkaroundSettingsFragment.kt | 2 +- .../BaseAccessibilityServiceController.kt | 10 +- .../inputmethod/AutoSwitchImeController.kt | 6 +- .../notifications/NotificationController.kt | 4 +- .../io/github/sds100/keymapper/util/Inject.kt | 2 +- .../{home => util/ui}/ChooseAppStoreModel.kt | 2 +- .../sds100/keymapper/util/ui/PopupUi.kt | 2 - .../keymapper/util/ui/compose/CompactChip.kt | 105 ++ .../res/layout/dialog_choose_app_store.xml | 2 +- 26 files changed, 1881 insertions(+), 1771 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/home/DeleteKeyMapsDialog.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/home/HomeWarningList.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt rename app/src/main/java/io/github/sds100/keymapper/mappings/{PauseMappingsUseCase.kt => PauseKeyMapsUseCase.kt} (91%) create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt rename app/src/main/java/io/github/sds100/keymapper/{home => util/ui}/ChooseAppStoreModel.kt (81%) create mode 100644 app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CompactChip.kt 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..47dfd58f7f 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 pauseMappings(ctx: Context) = PauseKeyMapsUseCaseImpl( ServiceLocator.settingsRepository(ctx), ServiceLocator.mediaAdapter(ctx), ) 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..a866af688d 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,7 @@ package io.github.sds100.keymapper.actions import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.home.ChooseAppStoreModel +import io.github.sds100.keymapper.util.ui.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 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..e388ae56f9 --- /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)) + } + }, + ) +} \ No newline at end of file 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..fac77e9bcf --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -0,0 +1,459 @@ +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.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row +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.rounded.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +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.Text +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.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.input.nestedscroll.nestedScroll +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.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.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.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 kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeKeyMapListScreen( + modifier: Modifier = Modifier, + viewModel: KeyMapListViewModel, + snackbarState: SnackbarHostState, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + finishActivity: () -> Unit, +) { + 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) { + val keyMapCount = (state.appBarState as? KeyMapAppBarState.Selecting)?.selectionCount ?: 0 + + 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) + val scope = rememberCoroutineScope() + + HomeKeyMapListScreen( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarState = snackbarState, + floatingActionButton = { + if (state.appBarState !is KeyMapAppBarState.Selecting) { + FloatingActionButton( + onClick = { + scope.launch { + viewModel.navigate( + NavigateEvent( + "config_key_map", + NavDestination.ConfigKeyMap(keyMapUid = null), + ), + ) + } + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val fabText = stringResource(R.string.home_fab_new_key_map) + Icon(Icons.Rounded.Add, contentDescription = fabText) + + AnimatedVisibility(viewModel.showFabText) { + AnimatedContent(fabText) { text -> + Text(modifier = Modifier.padding(start = 8.dp), text = fabText) + } + } + } + } + } + }, + listContent = { + KeyMapList( + modifier = modifier, + 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, + ) + }, + 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, + ) + }, + ) +} + +@Composable +private fun HomeKeyMapListScreen( + modifier: Modifier = Modifier, + snackbarState: SnackbarHostState, + appBarContent: @Composable () -> Unit, + listContent: @Composable () -> Unit, + floatingActionButton: @Composable () -> Unit, +) { + Scaffold( + modifier, + snackbarHost = { SnackbarHost(hostState = snackbarState) }, + topBar = appBarContent, + floatingActionButton = floatingActionButton, + ) { padding -> + Surface(modifier = Modifier.padding(padding)) { + listContent() + } + } +} + +@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) }, + ) + } + } +} + +@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 ImportDialogPreview() { + KeyMapperTheme { + ImportDialog( + keyMapCount = 3, + onDismissRequest = {}, + onAppendClick = {}, + onReplaceClick = {}, + ) + } +} + +@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/HomeScreen.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeScreen.kt index 9faa890129..033a60e2b6 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,38 @@ 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,261 +41,59 @@ 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 = { - // TODO use different app bars for each screen. - HomeAppBar( - scrollBehavior = scrollBehavior, - homeState = homeState, - 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, - ) - }, keyMapsContent = { - KeyMapListScreen( - modifier = Modifier.fillMaxSize(), + HomeKeyMapListScreen( viewModel = viewModel.keyMapListViewModel, - lazyListState = keyMapLazyListState, + snackbarState = snackbarState, + onSettingsClick = onSettingsClick, + onAboutClick = onAboutClick, + finishActivity = finishActivity, ) }, floatingButtonsContent = { FloatingLayoutsScreen( - Modifier.fillMaxSize(), 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 - } 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 }, - ) + selectionBottomSheet = { +// SelectionBottomSheet( +// enabled = selectionState.selectionCount > 0, +// selectedKeyMapsEnabled = state.selectedKeyMapsEnabled, +// onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, +// onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, +// onExportClick = viewModel::onExportSelectedKeyMaps, +// onDeleteClick = { showDeleteDialog = true }, +// ) }, ) } @@ -384,117 +101,25 @@ 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 = {}, + selectionBottomSheet: @Composable () -> 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 - } - - AnimatedVisibility( - homeState is HomeState.Normal, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - ) { - NavigationBar { - navBarItems.forEach { item -> - NavigationBarItem( - icon = { - if (item.badge == null) { - Icon(item.icon, contentDescription = null) - } else { - BadgedBox( - badge = { - Badge( - modifier = Modifier - .height(22.dp) - .padding(start = 10.dp), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) { - Text( - modifier = Modifier.padding(horizontal = 2.dp), - text = item.badge, - style = MaterialTheme.typography.labelLarge, - ) - } - }, - ) { - Icon(item.icon, contentDescription = null) - } - } - }, - label = { - Text( - item.label, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = currentDestination?.hierarchy?.any { it.route == item.destination.route } == true, - onClick = { - // don't do anything if clicking on the current - // destination because this results in some ugly animations. - if (currentDestination?.route == item.destination.route) { - return@NavigationBarItem - } - - navController.navigate(item.destination.route) { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when re-selecting a previously selected item - restoreState = true - } - }, - ) - } - } - } - }, - ) { 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, - ), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter, navController = navController, startDestination = startDestination.route, @@ -511,773 +136,80 @@ private fun HomeScreen( } } - AnimatedVisibility( - visible = homeState is HomeState.Selecting, + this@Column.AnimatedVisibility( + visible = isSelectingKeyMaps, enter = slideInVertically { it }, exit = slideOutVertically { it }, ) { - if (homeState is HomeState.Selecting) { - selectionBottomSheet(homeState) - } + selectionBottomSheet() } } - } -} - -@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) + AnimatedVisibility( + visible = isSelectingKeyMaps && navBarItems.size > 1, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + NavigationBar { + navBarItems.forEach { item -> + NavigationBarItem( + icon = { + if (item.badge == null) { + Icon(item.icon, contentDescription = null) } else { - stringResource(R.string.home_app_bar_select_all) + BadgedBox( + badge = { + Badge( + modifier = Modifier + .height(22.dp) + .padding(start = 10.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Text( + modifier = Modifier.padding(horizontal = 2.dp), + text = item.badge, + style = MaterialTheme.typography.labelLarge, + ) + } + }, + ) { + Icon(item.icon, contentDescription = null) + } } - Text(text) - } - } else { - Row { - IconButton(onClick = onHelpClick) { - Icon( - Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = stringResource(R.string.home_app_bar_help), - ) + }, + label = { + Text( + item.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = currentDestination?.hierarchy?.any { it.route == item.destination.route } == true, + onClick = { + // don't do anything if clicking on the current + // destination because this results in some ugly animations. + if (currentDestination?.route == item.destination.route) { + return@NavigationBarItem } - IconButton(onClick = { expandedDropdown = true }) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = stringResource(R.string.home_app_bar_more), - ) + navController.navigate(item.destination.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when re-selecting a previously selected item + restoreState = true } - - 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 3474597f53..aa0a235d29 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 @@ -15,28 +12,20 @@ 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 @@ -44,21 +33,16 @@ 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.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -67,7 +51,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, @@ -80,14 +64,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, @@ -108,9 +84,11 @@ class HomeViewModel( viewModelScope, listKeyMaps, resourceProvider, - multiSelectProvider, setupGuiKeyboard, sortKeyMaps, + showAlertsUseCase, + pauseKeyMaps, + backupRestore, ) } @@ -122,124 +100,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.keyMapGroup.map { it.keyMaps }.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() } @@ -302,28 +168,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), @@ -342,174 +186,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, @@ -533,20 +213,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..46d78949b0 --- /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)) + } + } + } + } + } +} \ No newline at end of file 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..396df48f6d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt @@ -0,0 +1,54 @@ +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 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)) + } + }, + ) +} \ No newline at end of file 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..5ac9497bbb --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -0,0 +1,364 @@ +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.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +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.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.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState +import io.github.sds100.keymapper.util.ui.compose.icons.Import +import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons + +@Composable +@OptIn(ExperimentalMaterial3Api::class) + fun KeyMapAppBar( + 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(), +) { + // 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 (state is KeyMapAppBarState.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) + + // TODO to solve the problem of the key map list jumping up when showing the bottom sheet: + // Always show it behind the bottom nav bar and add the necessary padding at the end of the list. The bottom sheet + // will then just replace the bottom sheet. + + Column { + CenterAlignedTopAppBar( + scrollBehavior = scrollBehavior, + title = { + when (state) { + is KeyMapAppBarState.RootGroup -> AppBarStatus( + state = state, + onTogglePausedClick = onTogglePausedClick, + ) + + is KeyMapAppBarState.Selecting -> SelectedText(selectionCount = state.selectionCount) + is KeyMapAppBarState.ChildGroup -> TODO() + } + }, + navigationIcon = { + AnimatedContent(state is KeyMapAppBarState.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(state is KeyMapAppBarState.Selecting) { isSelecting -> + if (isSelecting && state is KeyMapAppBarState.Selecting) { + 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) + } + } 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), + ) + } + + AppBarDropdownMenu( + expanded = expandedDropdown, + onSettingsClick = { + expandedDropdown = false + onSettingsClick() + }, + onAboutClick = { + expandedDropdown = false + onAboutClick() + }, + onExportClick = { + expandedDropdown = false + onExportClick() + }, + onImportClick = { + expandedDropdown = false + onImportClick() + }, + onDismissRequest = { expandedDropdown = false }, + ) + } + } + } + }, + colors = appBarColors, + ) + AnimatedVisibility(state is KeyMapAppBarState.RootGroup && state.warnings.isNotEmpty()) { + Surface(color = appBarContainerColor) { + HomeWarningList( + modifier = Modifier.padding(bottom = 8.dp), + warnings = (state as? KeyMapAppBarState.RootGroup)?.warnings ?: emptyList(), + onFixClick = onFixWarningClick, + ) + } + } + } +} + +@Composable +private fun AppBarStatus( + state: KeyMapAppBarState.RootGroup, + onTogglePausedClick: () -> Unit, +) { + val pausedButtonContainerColor by animateColorAsState( + targetValue = if (state.isPaused || state.warnings.isNotEmpty()) { + MaterialTheme.colorScheme.errorContainer + } else { + LocalCustomColorsPalette.current.greenContainer + }, + ) + + val pausedButtonContentColor by animateColorAsState( + targetValue = if (state.isPaused || state.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 (state.isPaused) { + buttonIcon = Icons.Rounded.PauseCircleOutline + buttonText = stringResource(R.string.home_app_bar_status_paused) + } else if (state.warnings.isNotEmpty()) { + buttonIcon = Icons.Rounded.ErrorOutline + buttonText = pluralStringResource( + R.plurals.home_app_bar_status_warnings, + state.warnings.size, + state.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 = {}, +) { + 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, + ) + } +} 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..9be0f9acc3 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -0,0 +1,182 @@ +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.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.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.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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R + +@Composable +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) + }, + ) + } +} 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/CreateKeyMapShortcutScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutScreen.kt index 40f2dd3b00..c608a348ba 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 @@ -113,7 +113,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/KeyMapAppBarState.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt new file mode 100644 index 0000000000..60e343ef0c --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapAppBarState.kt @@ -0,0 +1,29 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import io.github.sds100.keymapper.constraints.ConstraintMode +import io.github.sds100.keymapper.groups.SubGroupListModel +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, + + ) : KeyMapAppBarState() + + data class Selecting( + val selectionCount: Int, + val selectedKeyMapsEnabled: SelectedKeyMapsEnabled, + val isAllSelected: Boolean, + ) : KeyMapAppBarState() +} 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 dedb954cb3..bf115177da 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,20 +23,16 @@ 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 import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard -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 +46,10 @@ 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.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 +59,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 state by viewModel.state.collectAsStateWithLifecycle() - val isSelectable by viewModel.isSelectable.collectAsStateWithLifecycle() - - KeyMapListScreen( - modifier = modifier, - lazyListState = lazyListState, - listItems = state.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>, @@ -109,36 +77,38 @@ fun KeyMapListScreen( onFixClick: (Error) -> Unit = {}, onTriggerErrorClick: (TriggerError) -> Unit = {}, ) { - Surface(modifier = modifier) { - when (listItems) { - is State.Loading -> { - Box { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } + when (listItems) { + is State.Loading -> { + LoadingList() + } - is State.Data -> { - if (listItems.data.isEmpty()) { - EmptyKeyMapList(modifier = modifier) - } else { - KeyMapList( - modifier, - lazyListState, - listItems.data, - footerText, - isSelectable, - onClickKeyMap, - onLongClickKeyMap, - onSelectedChange, - onFixClick, - onTriggerErrorClick, - ) - } + is State.Data -> { + if (listItems.data.isEmpty()) { + EmptyKeyMapList(modifier = modifier) + } else { + LoadedKeyMapList( + modifier, + lazyListState, + listItems.data, + footerText, + isSelectable, + onClickKeyMap, + onLongClickKeyMap, + onSelectedChange, + onFixClick, + onTriggerErrorClick, + ) } } } } +@Composable +private fun LoadingList() { + Box { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } +} @Composable private fun EmptyKeyMapList(modifier: Modifier = Modifier) { Box(modifier) { @@ -162,7 +132,7 @@ private fun EmptyKeyMapList(modifier: Modifier = Modifier) { } @Composable -private fun KeyMapList( +private fun LoadedKeyMapList( modifier: Modifier = Modifier, lazyListState: LazyListState, listItems: List, @@ -293,7 +263,7 @@ private fun KeyMapListItem( ), ) { for (error in model.content.triggerErrors) { - ErrorChip( + ErrorCompactChip( onClick = { onTriggerErrorClick(error) }, text = getTriggerErrorMessage(error), enabled = error.isFixable, @@ -487,7 +457,7 @@ private fun ActionConstraintChip( ) } - is ComposeChipModel.Error -> ErrorChip( + is ComposeChipModel.Error -> ErrorCompactChip( onClick = { onFixClick(model.error) }, model.text, model.isFixable, @@ -495,87 +465,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 +625,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 +633,7 @@ private fun ListPreview() { @Composable private fun SelectableListPreview() { KeyMapperTheme { - KeyMapListScreen( + KeyMapList( modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList()), isSelectable = true, @@ -756,7 +645,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 +653,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 index 108d2b0ace..9f79721767 100644 --- 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 @@ -1,26 +1,9 @@ package io.github.sds100.keymapper.mappings.keymaps -import io.github.sds100.keymapper.constraints.ConstraintMode -import io.github.sds100.keymapper.groups.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel import io.github.sds100.keymapper.util.State -import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel -sealed class KeyMapListState { - abstract val subGroups: List - abstract val listItems: State> - - data class Root( - override val subGroups: List = emptyList(), - override val listItems: State> = State.Loading, - ) : KeyMapListState() - - data class Child( - val groupName: String, - val constraints: List, - val constraintMode: ConstraintMode, - override val subGroups: List, - override val listItems: State>, - - ) : KeyMapListState() -} +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 10ad8d99b5..f32465a24b 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,39 +3,60 @@ 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.groups.SubGroupListModel +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.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.CoroutineScope import kotlinx.coroutines.Dispatchers +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.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -45,24 +66,43 @@ class KeyMapListViewModel( private val coroutineScope: CoroutineScope, private val listKeyMaps: ListKeyMapsUseCase, resourceProvider: ResourceProvider, - private val multiSelectProvider: MultiSelectProvider, private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val sortKeyMaps: SortKeyMapsUseCase, + private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, + private val pauseKeyMaps: PauseKeyMapsUseCase, + private val backupRestore: BackupRestoreMappingsUseCase, + ) : PopupViewModel by PopupViewModelImpl(), ResourceProvider by resourceProvider, NavigationViewModel by NavigationViewModelImpl() { + private companion object { + const val ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM = "accessibility_service_disabled" + const val ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM = "accessibility_service_crashed" + const val ID_BATTERY_OPTIMISATION_LIST_ITEM = "battery_optimised" + const val ID_LOGGING_ENABLED_LIST_ITEM = "logging_enabled" + } + + val sortViewModel = SortViewModel(coroutineScope, sortKeyMaps) + var showSortBottomSheet by mutableStateOf(false) + + val multiSelectProvider: MultiSelectProvider = MultiSelectProvider() + private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) - private val _state = MutableStateFlow(KeyMapListState.Root()) + 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, @@ -87,111 +127,216 @@ class KeyMapListViewModel( ), ) + 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), + ), + ) + } + } + } + init { - val keyMapGroupFlow = combine( - keyMapGroupStateFlow, + val sortedKeyMapsFlow = combine( + keyMapGroupStateFlow.map { it.keyMaps }.distinctUntilChanged(), sortKeyMaps.observeKeyMapsSorter(), - ) { keyMapGroup, sorter -> - keyMapGroup.copy( - keyMaps = keyMapGroup.keyMaps.mapData { list -> list.sortedWith(sorter) }, - ) + ) { keyMapsState, sorter -> + keyMapsState.mapData { list -> list.sortedWith(sorter) } }.flowOn(Dispatchers.Default) - val listStateFlow = + val listItemContentFlow = combine( - keyMapGroupFlow, + sortedKeyMapsFlow, listKeyMaps.showDeviceDescriptors, listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, - transform = ::buildState, + 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 appBarStateFlow = combine( + keyMapGroupStateFlow, + warnings, + multiSelectProvider.state, + pauseKeyMaps.isPaused, + listKeyMaps.constraintErrorSnapshot, + transform = ::buildAppBarState, + ) + + coroutineScope.launch { combine( - listStateFlow, - multiSelectProvider.state, - ) { listState, selectionState -> - Pair(listState, selectionState) - }.collectLatest { (listState, selectionState) -> - // Stop selecting when there are no key maps - listState.listItems.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 = listState.listItems.dataOrNull()?.isEmpty() ?: true + _state.value = KeyMapListState(appBarState, listState) + } + } - val listItemsWithSelection = listState.listItems.mapData { listItems -> - listItems.map { item -> - val isSelected = if (selectionState is SelectionState.Selecting) { - selectionState.selectedIds.contains(item.uid) + coroutineScope.launch { + backupRestore.onAutomaticBackupResult.collectLatest { result -> + onAutomaticBackupResult(result) + } + } + } + + private fun buildAppBarState( + keyMapGroup: KeyMapGroup, + warnings: List, + selectionState: SelectionState, + isPaused: Boolean, + constraintErrorSnapshot: ConstraintErrorSnapshot, + ): KeyMapAppBarState { + if (selectionState is SelectionState.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 { - false + SelectedKeyMapsEnabled.NONE + } + } else { + if ((keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.NONE) || + (!keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.ALL) + ) { + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED + break } - - item.copy(isSelected = isSelected) } } + } - _state.value = when (listState) { - is KeyMapListState.Root -> listState.copy(listItems = listItemsWithSelection) - is KeyMapListState.Child -> listState.copy(listItems = listItemsWithSelection) - } + return KeyMapAppBarState.Selecting( + selectionCount = selectionState.selectedIds.size, + selectedKeyMapsEnabled = selectedKeyMapsEnabled ?: SelectedKeyMapsEnabled.NONE, + isAllSelected = selectionState.selectedIds.size == keyMaps.size, + ) + } else { + val subGroupListItems = keyMapGroup.subGroups.map { group -> + SubGroupListModel( + uid = group.uid, + name = group.name, + icon = null, // TODO show icon depending on constraints + ) + } + + 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, + ) } } } - private fun buildState( - keyMapGroup: KeyMapGroup, + private fun buildListItems( + keyMapsState: State>, showDeviceDescriptors: Boolean, triggerErrorSnapshot: TriggerErrorSnapshot, actionErrorSnapshot: ActionErrorSnapshot, constraintErrorSnapshot: ConstraintErrorSnapshot, - ): KeyMapListState { - val listItemsState = keyMapGroup.keyMaps.mapData { list -> + ): State> { + return keyMapsState.mapData { list -> list.map { - val content = listItemCreator.build( + listItemCreator.build( it, showDeviceDescriptors, triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot, ) - - KeyMapListItemModel(isSelected = false, content) } } - - val subGroupListItems = keyMapGroup.subGroups.map { group -> - SubGroupListModel( - uid = group.uid, - name = group.name, - icon = null, // TODO show icon depending on constraints - ) - } - - if (keyMapGroup.group == null) { - return KeyMapListState.Root( - subGroups = subGroupListItems, - listItems = listItemsState, - ) - } else { - return KeyMapListState.Child( - groupName = keyMapGroup.group.name, - constraints = listItemCreator.buildConstraintChipList( - keyMapGroup.group.constraintState, - constraintErrorSnapshot, - ), - constraintMode = keyMapGroup.group.constraintState.mode, - subGroups = subGroupListItems, - listItems = listItemsState, - ) - } } fun onKeyMapCardClick(uid: String) { @@ -227,18 +372,6 @@ class KeyMapListViewModel( } } - fun selectAll() { - coroutineScope.launch { - state.value.listItems.apply { - if (this is State.Data) { - multiSelectProvider.select( - *this.data.map { it.uid }.toTypedArray(), - ) - } - } - } - } - fun onFixTriggerError(error: TriggerError) { coroutineScope.launch { when (error) { @@ -308,4 +441,196 @@ 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 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 { + if (multiSelectProvider.state.value is SelectionState.Selecting) { + multiSelectProvider.stopSelecting() + return true + } else { + return false + } + } + + 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/settings/Android11BugWorkaroundSettingsFragment.kt b/app/src/main/java/io/github/sds100/keymapper/settings/Android11BugWorkaroundSettingsFragment.kt index 94f73f78ad..996a38172d 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,7 +8,7 @@ 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.util.ui.ChooseAppStoreModel import io.github.sds100.keymapper.system.leanback.LeanbackUtils import io.github.sds100.keymapper.system.url.UrlUtils import io.github.sds100.keymapper.util.drawable diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 1c6981df4b..8dee0e0ec6 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 @@ -67,7 +67,7 @@ abstract class BaseAccessibilityServiceController( private val detectKeyMapsUseCase: DetectKeyMapsUseCase, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, rerouteKeyEventsUseCase: RerouteKeyEventsUseCase, - private val pauseMappingsUseCase: PauseMappingsUseCase, + private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, private val devicesAdapter: DevicesAdapter, private val suAdapter: SuAdapter, private val inputMethodAdapter: InputMethodAdapter, @@ -106,7 +106,7 @@ abstract class BaseAccessibilityServiceController( private val recordDpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() - val isPaused: StateFlow = pauseMappingsUseCase.isPaused + val isPaused: StateFlow = pauseKeyMapsUseCase.isPaused .stateIn(coroutineScope, SharingStarted.Eagerly, false) private val screenOffTriggersEnabled: StateFlow = @@ -204,7 +204,7 @@ abstract class BaseAccessibilityServiceController( }.launchIn(coroutineScope) } - pauseMappingsUseCase.isPaused.distinctUntilChanged().onEach { + pauseKeyMapsUseCase.isPaused.distinctUntilChanged().onEach { keyMapController.reset() triggerKeyMapFromOtherAppsController.reset() }.launchIn(coroutineScope) @@ -234,7 +234,7 @@ abstract class BaseAccessibilityServiceController( }.launchIn(coroutineScope) combine( - pauseMappingsUseCase.isPaused, + pauseKeyMapsUseCase.isPaused, detectKeyMapsUseCase.allKeyMapList, ) { isPaused, keyMaps -> val enableAccessibilityVolumeStream: Boolean 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/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index 133f25be7b..76bf998a5f 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 @@ -215,7 +215,7 @@ object Inject { keyEventRelayService = keyEventRelayService, ), fingerprintGesturesSupportedUseCase = UseCases.fingerprintGesturesSupported(service), - pauseMappingsUseCase = UseCases.pauseMappings(service), + pauseKeyMapsUseCase = UseCases.pauseMappings(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/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/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/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" /> Date: Fri, 28 Mar 2025 12:58:37 -0600 Subject: [PATCH 07/94] #320 create previews for key map app bar and list. selection bottom sheet works again --- .../keymapper/home/HomeKeyMapListScreen.kt | 491 +++++++++++++----- .../sds100/keymapper/home/HomeScreen.kt | 136 ++--- .../sds100/keymapper/home/ImportDialog.kt | 17 +- .../sds100/keymapper/home/KeyMapAppBar.kt | 143 ++++- .../keymaps/CreateKeyMapShortcutScreen.kt | 1 + .../keymaps/CreateKeyMapShortcutViewModel.kt | 30 +- .../mappings/keymaps/KeyMapListScreen.kt | 44 +- 7 files changed, 609 insertions(+), 253 deletions(-) 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 index fac77e9bcf..5f85c5bc61 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -5,11 +5,17 @@ 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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row 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.material.icons.rounded.Add import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -32,26 +38,34 @@ 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.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.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.constraints.ConstraintMode 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.NavDestination import io.github.sds100.keymapper.util.ui.NavigateEvent +import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -109,8 +123,8 @@ fun HomeKeyMapListScreen( var showDeleteDialog by rememberSaveable { mutableStateOf(false) } - if (showDeleteDialog) { - val keyMapCount = (state.appBarState as? KeyMapAppBarState.Selecting)?.selectionCount ?: 0 + if (showDeleteDialog && state.appBarState is KeyMapAppBarState.Selecting) { + val keyMapCount = (state.appBarState as KeyMapAppBarState.Selecting).selectionCount DeleteKeyMapsDialog( keyMapCount = keyMapCount, @@ -131,8 +145,9 @@ fun HomeKeyMapListScreen( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), snackbarState = snackbarState, floatingActionButton = { - if (state.appBarState !is KeyMapAppBarState.Selecting) { - FloatingActionButton( + AnimatedVisibility(state.appBarState !is KeyMapAppBarState.Selecting) { + NewKeyMapFab( + modifier = Modifier.padding(bottom = 80.dp), onClick = { scope.launch { viewModel.navigate( @@ -143,21 +158,8 @@ fun HomeKeyMapListScreen( ) } }, - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val fabText = stringResource(R.string.home_fab_new_key_map) - Icon(Icons.Rounded.Add, contentDescription = fabText) - - AnimatedVisibility(viewModel.showFabText) { - AnimatedContent(fabText) { text -> - Text(modifier = Modifier.padding(start = 8.dp), text = fabText) - } - } - } - } + showText = viewModel.showFabText, + ) } }, listContent = { @@ -194,16 +196,40 @@ fun HomeKeyMapListScreen( onSelectAllClick = viewModel::onSelectAllClick, ) }, + 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, + ) + + SelectionBottomSheet( + enabled = selectionState.selectionCount > 0, + selectedKeyMapsEnabled = selectionState.selectedKeyMapsEnabled, + onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, + onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, + onExportClick = viewModel::onExportSelectedKeyMaps, + onDeleteClick = { showDeleteDialog = true }, + ) + } + }, ) } @Composable private fun HomeKeyMapListScreen( modifier: Modifier = Modifier, - snackbarState: SnackbarHostState, + snackbarState: SnackbarHostState = SnackbarHostState(), appBarContent: @Composable () -> Unit, listContent: @Composable () -> Unit, floatingActionButton: @Composable () -> Unit, + selectionBottomSheet: @Composable () -> Unit, ) { Scaffold( modifier, @@ -212,7 +238,10 @@ private fun HomeKeyMapListScreen( floatingActionButton = floatingActionButton, ) { padding -> Surface(modifier = Modifier.padding(padding)) { - listContent() + Box(contentAlignment = Alignment.BottomCenter) { + listContent() + selectionBottomSheet() + } } } } @@ -279,119 +308,205 @@ fun HandleImportExportState( } } -@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 = {}, - ) +private fun NewKeyMapFab( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + showText: Boolean, +) { + FloatingActionButton( + modifier = modifier, + onClick = onClick, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val fabText = stringResource(R.string.home_fab_new_key_map) + Icon(Icons.Rounded.Add, contentDescription = fabText) + + AnimatedVisibility(showText) { + AnimatedContent(fabText) { text -> + Text(modifier = Modifier.padding(start = 8.dp), text = fabText) + } + } + } } } -@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), +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", ), - HomeWarningListItem( - id = "1", - text = stringResource(R.string.home_error_is_battery_optimised), + ), + 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, ), ), - 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), + 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, ), - HomeWarningListItem( - id = "1", - text = stringResource(R.string.home_error_is_battery_optimised), + ), + 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", ), ), - 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) +@Preview @Composable -private fun HomeStateSelectingPreview() { - val state = HomeState.Selecting( - selectionCount = 4, +private fun PreviewSelectingKeyMaps() { + val appBarState = KeyMapAppBarState.Selecting( + selectionCount = 2, selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, isAllSelected = false, ) + + val listState = State.Data(sampleList()) + KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, + 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.ALL, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, ) }, ) @@ -399,61 +514,155 @@ private fun HomeStateSelectingPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable -private fun HomeStateSelectingDisabledPreview() { - val state = HomeState.Selecting( - selectionCount = 4, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, - isAllSelected = true, +private fun PreviewKeyMapsRunning() { + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = false, ) + + val listState = State.Data(sampleList()) + KeyMapperTheme { - HomeScreen( - navController = rememberNavController(), - homeState = state, - navBarItems = sampleNavBarItems(), - topAppBar = { HomeAppBar(state) }, - keyMapsContent = {}, - floatingButtonsContent = {}, - selectionBottomSheet = { - SelectionBottomSheet( - enabled = false, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, + HomeKeyMapListScreen( + floatingActionButton = { + NewKeyMapFab(showText = true) + }, + 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 ImportDialogPreview() { +private fun PreviewKeyMapsPaused() { + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = true, + ) + + val listState = State.Data(sampleList()) + KeyMapperTheme { - ImportDialog( - keyMapCount = 3, - onDismissRequest = {}, - onAppendClick = {}, - onReplaceClick = {}, + HomeKeyMapListScreen( + floatingActionButton = { + NewKeyMapFab(showText = true) + }, + 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 DropdownPreview() { +private fun PreviewKeyMapsWarnings() { + 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(sampleList()) + KeyMapperTheme { - HomeDropdownMenu( - expanded = true, + HomeKeyMapListScreen( + floatingActionButton = { + NewKeyMapFab(showText = true) + }, + 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 DropdownExportingPreview() { +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 { - HomeDropdownMenu( - expanded = true, + HomeKeyMapListScreen( + floatingActionButton = { + NewKeyMapFab(showText = true) + }, + 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 033a60e2b6..1ce0e0d2b2 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 @@ -28,7 +28,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -57,11 +56,7 @@ fun HomeScreen( val navController = rememberNavController() val navBarItems by viewModel.navBarItems.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() val snackbarState = remember { SnackbarHostState() } - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - val selectionState by viewModel.keyMapListViewModel.multiSelectProvider.state.collectAsStateWithLifecycle() HomeScreen( @@ -85,16 +80,6 @@ fun HomeScreen( navController = navController, ) }, - selectionBottomSheet = { -// SelectionBottomSheet( -// enabled = selectionState.selectionCount > 0, -// selectedKeyMapsEnabled = state.selectedKeyMapsEnabled, -// onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, -// onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, -// onExportClick = viewModel::onExportSelectedKeyMaps, -// onDeleteClick = { showDeleteDialog = true }, -// ) - }, ) } @@ -107,7 +92,6 @@ private fun HomeScreen( navBarItems: List, keyMapsContent: @Composable () -> Unit, floatingButtonsContent: @Composable () -> Unit, - selectionBottomSheet: @Composable () -> Unit = {}, ) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -137,77 +121,69 @@ private fun HomeScreen( } this@Column.AnimatedVisibility( - visible = isSelectingKeyMaps, + visible = !isSelectingKeyMaps && navBarItems.size > 1, enter = slideInVertically { it }, exit = slideOutVertically { it }, ) { - selectionBottomSheet() - } - } - - AnimatedVisibility( - visible = isSelectingKeyMaps && navBarItems.size > 1, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - ) { - NavigationBar { - navBarItems.forEach { item -> - NavigationBarItem( - icon = { - if (item.badge == null) { - Icon(item.icon, contentDescription = null) - } else { - BadgedBox( - badge = { - Badge( - modifier = Modifier - .height(22.dp) - .padding(start = 10.dp), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) { - Text( - modifier = Modifier.padding(horizontal = 2.dp), - text = item.badge, - style = MaterialTheme.typography.labelLarge, - ) - } - }, - ) { + NavigationBar { + navBarItems.forEach { item -> + NavigationBarItem( + icon = { + if (item.badge == null) { Icon(item.icon, contentDescription = null) + } else { + BadgedBox( + badge = { + Badge( + modifier = Modifier + .height(22.dp) + .padding(start = 10.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Text( + modifier = Modifier.padding(horizontal = 2.dp), + text = item.badge, + style = MaterialTheme.typography.labelLarge, + ) + } + }, + ) { + Icon(item.icon, contentDescription = null) + } + } + }, + label = { + Text( + item.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = currentDestination?.hierarchy?.any { it.route == item.destination.route } == true, + onClick = { + // don't do anything if clicking on the current + // destination because this results in some ugly animations. + if (currentDestination?.route == item.destination.route) { + return@NavigationBarItem } - } - }, - label = { - Text( - item.label, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = currentDestination?.hierarchy?.any { it.route == item.destination.route } == true, - onClick = { - // don't do anything if clicking on the current - // destination because this results in some ugly animations. - if (currentDestination?.route == item.destination.route) { - return@NavigationBarItem - } - navController.navigate(item.destination.route) { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + navController.navigate(item.destination.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when re-selecting a previously selected item + restoreState = true } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when re-selecting a previously selected item - restoreState = true - } - }, - ) + }, + ) + } } } } 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 index 396df48f6d..e24d3305ce 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt @@ -8,7 +8,9 @@ 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( @@ -51,4 +53,17 @@ import io.github.sds100.keymapper.R } }, ) -} \ No newline at end of file +} + +@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 index 5ac9497bbb..a0f8199ca1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -55,18 +55,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.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.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.constraints.ConstraintMode 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.icons.Import import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons @Composable @OptIn(ExperimentalMaterial3Api::class) - fun KeyMapAppBar( +fun KeyMapAppBar( state: KeyMapAppBarState, onSettingsClick: () -> Unit = {}, onAboutClick: () -> Unit = {}, @@ -362,3 +370,136 @@ private fun AppBarDropdownMenu( ) } } + +@Composable +private fun constraintsSampleList(): List { + val context = LocalContext.current + + return 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(""), + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun KeyMapsChildGroupPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "My group", + subGroups = emptyList(), + constraints = constraintsSampleList(), + constraintMode = ConstraintMode.AND, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} + +@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 { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(widthDp = 300, heightDp = 600) +@Composable +private fun HomeStateSelectingPreview() { + val state = KeyMapAppBarState.Selecting( + selectionCount = 4, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + isAllSelected = false, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun HomeStateSelectingDisabledPreview() { + val state = KeyMapAppBarState.Selecting( + selectionCount = 4, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + isAllSelected = true, + ) + KeyMapperTheme { + KeyMapAppBar(state = state) + } +} 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 c608a348ba..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 @@ -92,6 +92,7 @@ private fun CreateKeyMapShortcutScreen( ) } + // TODO allow navigating between groups and hide the FAB. Scaffold( modifier = modifier, bottomBar = { 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 ca5313c64b..6120e084b2 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 @@ -12,6 +12,7 @@ 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.groups.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapListItemModel import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerErrorSnapshot @@ -42,7 +43,15 @@ class CreateKeyMapShortcutViewModel( private val actionUiHelper = ActionUiHelper(listKeyMaps, resourceProvider) private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) - private val _state = MutableStateFlow(KeyMapListState.Root()) + 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() @@ -100,23 +109,22 @@ class CreateKeyMapShortcutViewModel( ) } - if (keyMapGroup.group == null) { - return KeyMapListState.Root( + val appBarState = if (keyMapGroup.group == null) { + KeyMapAppBarState.RootGroup( subGroups = subGroupListItems, - listItems = listItemsState, + warnings = emptyList(), + isPaused = false, ) } else { - return KeyMapListState.Child( + KeyMapAppBarState.ChildGroup( groupName = keyMapGroup.group.name, - constraints = listItemCreator.buildConstraintChipList( - keyMapGroup.group.constraintState, - constraintErrorSnapshot, - ), - constraintMode = keyMapGroup.group.constraintState.mode, subGroups = subGroupListItems, - listItems = listItemsState, + constraints = emptyList(), + constraintMode = ConstraintMode.AND, ) } + + return KeyMapListState(appBarState, listItemsState) } fun onKeyMapCardClick(uid: String) { 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 bf115177da..399429d31c 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 @@ -30,6 +30,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -79,36 +80,41 @@ fun KeyMapList( ) { when (listItems) { is State.Loading -> { - LoadingList() + Surface(modifier = modifier) { + LoadingList(modifier = Modifier.fillMaxSize()) + } } is State.Data -> { - if (listItems.data.isEmpty()) { - EmptyKeyMapList(modifier = modifier) - } else { - LoadedKeyMapList( - modifier, - lazyListState, - listItems.data, - footerText, - isSelectable, - onClickKeyMap, - onLongClickKeyMap, - onSelectedChange, - onFixClick, - onTriggerErrorClick, - ) + Surface(modifier = modifier) { + if (listItems.data.isEmpty()) { + EmptyKeyMapList(modifier = Modifier.fillMaxSize()) + } else { + LoadedKeyMapList( + Modifier.fillMaxSize(), + lazyListState, + listItems.data, + footerText, + isSelectable, + onClickKeyMap, + onLongClickKeyMap, + onSelectedChange, + onFixClick, + onTriggerErrorClick, + ) + } } } } } @Composable -private fun LoadingList() { - Box { +private fun LoadingList(modifier: Modifier = Modifier) { + Box(modifier) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } + @Composable private fun EmptyKeyMapList(modifier: Modifier = Modifier) { Box(modifier) { @@ -181,7 +187,7 @@ private fun LoadedKeyMapList( // 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(140.dp)) } } } From 9e6e010535e4e057e5749c876c8ee3b2fa663fa4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 13:31:40 -0600 Subject: [PATCH 08/94] #320 create floating layouts app bar --- .../keymapper/home/HomeKeyMapListScreen.kt | 56 +++++++------------ .../sds100/keymapper/home/HomeScreen.kt | 4 +- .../CollapsableFloatingActionButton.kt | 41 ++++++++++++++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 63 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/util/ui/compose/CollapsableFloatingActionButton.kt 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 index 5f85c5bc61..a346dae5b3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -3,12 +3,10 @@ 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.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.rememberLazyListState @@ -16,16 +14,12 @@ 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.material.icons.rounded.Add import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon 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.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -64,6 +58,7 @@ import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.drawable import io.github.sds100.keymapper.util.ui.NavDestination import io.github.sds100.keymapper.util.ui.NavigateEvent +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 import kotlinx.coroutines.launch @@ -146,7 +141,7 @@ fun HomeKeyMapListScreen( snackbarState = snackbarState, floatingActionButton = { AnimatedVisibility(state.appBarState !is KeyMapAppBarState.Selecting) { - NewKeyMapFab( + CollapsableFloatingActionButton( modifier = Modifier.padding(bottom = 80.dp), onClick = { scope.launch { @@ -159,6 +154,7 @@ fun HomeKeyMapListScreen( } }, showText = viewModel.showFabText, + text = stringResource(R.string.home_fab_new_key_map), ) } }, @@ -308,32 +304,6 @@ fun HandleImportExportState( } } -@Composable -private fun NewKeyMapFab( - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, - showText: Boolean, -) { - FloatingActionButton( - modifier = modifier, - onClick = onClick, - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val fabText = stringResource(R.string.home_fab_new_key_map) - Icon(Icons.Rounded.Add, contentDescription = fabText) - - AnimatedVisibility(showText) { - AnimatedContent(fabText) { text -> - Text(modifier = Modifier.padding(start = 8.dp), text = fabText) - } - } - } - } -} - @Composable private fun sampleList(): List { val context = LocalContext.current @@ -528,7 +498,10 @@ private fun PreviewKeyMapsRunning() { KeyMapperTheme { HomeKeyMapListScreen( floatingActionButton = { - NewKeyMapFab(showText = true) + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) }, listContent = { KeyMapList( @@ -561,7 +534,10 @@ private fun PreviewKeyMapsPaused() { KeyMapperTheme { HomeKeyMapListScreen( floatingActionButton = { - NewKeyMapFab(showText = true) + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) }, listContent = { KeyMapList( @@ -605,7 +581,10 @@ private fun PreviewKeyMapsWarnings() { KeyMapperTheme { HomeKeyMapListScreen( floatingActionButton = { - NewKeyMapFab(showText = true) + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) }, listContent = { KeyMapList( @@ -649,7 +628,10 @@ private fun PreviewKeyMapsWarningsEmpty() { KeyMapperTheme { HomeKeyMapListScreen( floatingActionButton = { - NewKeyMapFab(showText = true) + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) }, listContent = { KeyMapList( 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 1ce0e0d2b2..fafd6a4728 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 @@ -40,7 +40,6 @@ 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.floating.FloatingLayoutsScreen import io.github.sds100.keymapper.util.ui.SelectionState @OptIn(ExperimentalMaterial3Api::class) @@ -75,9 +74,10 @@ fun HomeScreen( ) }, floatingButtonsContent = { - FloatingLayoutsScreen( + HomeFloatingLayoutsScreen( viewModel = viewModel.listFloatingLayoutsViewModel, navController = navController, + snackbarState = snackbarState, ) }, ) 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9995eaaecc..621ed98dc1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1302,6 +1302,7 @@ Cancel Hide floating layouts You can find floating buttons in the Advanced Triggers button when creating a trigger. + Floating buttons Menu Sort More From be75b8230f067f586a4c5f6197b167921836347d Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 13:59:20 -0600 Subject: [PATCH 09/94] #320 show new group button --- .../sds100/keymapper/groups/GroupRow.kt | 82 +++++++++++++++++++ .../keymapper/home/HomeKeyMapListScreen.kt | 21 ++++- .../sds100/keymapper/home/KeyMapAppBar.kt | 60 ++++++++++++-- app/src/main/res/values/strings.xml | 6 ++ 4 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt 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..2ce5ec5a1e --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -0,0 +1,82 @@ +package io.github.sds100.keymapper.groups + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.FlowRow +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.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme + +@Composable +fun GroupRow( + modifier: Modifier = Modifier, + groups: List, + onNewGroupClick: () -> Unit = {}, + onGroupClick: (String) -> Unit = {}, +) { + FlowRow(modifier) { + AnimatedContent(groups.isEmpty()) { isEmpty -> + GroupButton( + onClick = onNewGroupClick, + text = stringResource(R.string.home_new_group_button), + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = isEmpty, + ) + } + // TODO only show max 2 rows, otherwise show View All. + } +} + +@Composable +private fun GroupButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String, + icon: @Composable () -> Unit, + showText: Boolean = true, +) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + GroupRow(groups = emptyList()) + } +} 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 index a346dae5b3..883a369a0d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -4,7 +4,11 @@ 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.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 @@ -44,6 +48,7 @@ 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.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState import io.github.sds100.keymapper.mappings.keymaps.KeyMapList import io.github.sds100.keymapper.mappings.keymaps.KeyMapListViewModel @@ -140,7 +145,11 @@ fun HomeKeyMapListScreen( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), snackbarState = snackbarState, floatingActionButton = { - AnimatedVisibility(state.appBarState !is KeyMapAppBarState.Selecting) { + AnimatedVisibility( + state.appBarState !is KeyMapAppBarState.Selecting, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }), + ) { CollapsableFloatingActionButton( modifier = Modifier.padding(bottom = 80.dp), onClick = { @@ -559,6 +568,8 @@ private fun PreviewKeyMapsPaused() { @Preview @Composable private fun PreviewKeyMapsWarnings() { + val ctx = LocalContext.current + val warnings = listOf( HomeWarningListItem( id = "0", @@ -571,7 +582,13 @@ private fun PreviewKeyMapsWarnings() { ) val appBarState = KeyMapAppBarState.RootGroup( - subGroups = emptyList(), + subGroups = listOf( + SubGroupListModel( + uid = "0", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ), warnings = warnings, isPaused = true, ) 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 index a0f8199ca1..c80a9b3913 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -64,6 +65,8 @@ 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.GroupRow +import io.github.sds100.keymapper.groups.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.drawable @@ -216,13 +219,41 @@ fun KeyMapAppBar( }, colors = appBarColors, ) - AnimatedVisibility(state is KeyMapAppBarState.RootGroup && state.warnings.isNotEmpty()) { - Surface(color = appBarContainerColor) { - HomeWarningList( - modifier = Modifier.padding(bottom = 8.dp), - warnings = (state as? KeyMapAppBarState.RootGroup)?.warnings ?: emptyList(), - onFixClick = onFixWarningClick, - ) + + Column { + AnimatedVisibility( + visible = state is KeyMapAppBarState.RootGroup && 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, + ) + } + } + + AnimatedVisibility( + visible = state !is KeyMapAppBarState.Selecting, + ) { + val subGroups = when (state) { + is KeyMapAppBarState.ChildGroup -> state.subGroups + is KeyMapAppBarState.RootGroup -> state.subGroups + is KeyMapAppBarState.Selecting -> emptyList() + } + + Surface(color = appBarContainerColor) { + GroupRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + groups = subGroups, + onNewGroupClick = {}, + onGroupClick = {}, + ) + } } } } @@ -389,13 +420,26 @@ private fun constraintsSampleList(): List { ) } +@Composable +private fun groupSampleList(): List { + val ctx = LocalContext.current + + return listOf( + SubGroupListModel( + uid = "0", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable private fun KeyMapsChildGroupPreview() { val state = KeyMapAppBarState.ChildGroup( groupName = "My group", - subGroups = emptyList(), + subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 621ed98dc1..04d6e68128 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1354,6 +1354,12 @@ Cancel Save to files + New group + New subgroup + View all + Group constraints + New group constraint + Remove Edit From 4f84e27f3c6760dae3b3cbb1a59c98dd6dd93ed9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 16:30:56 -0600 Subject: [PATCH 10/94] #320 WIP: redo app bars --- .../sds100/keymapper/data/db/dao/GroupDao.kt | 16 + .../keymapper/data/entities/GroupEntity.kt | 3 +- .../data/repositories/GroupRepository.kt | 34 +- .../keymapper/home/HomeKeyMapListScreen.kt | 3 + .../sds100/keymapper/home/KeyMapAppBar.kt | 512 +++++++++++++----- .../mappings/keymaps/KeyMapListViewModel.kt | 50 +- .../mappings/keymaps/ListKeyMapsUseCase.kt | 69 +++ .../io/github/sds100/keymapper/util/Inject.kt | 2 + app/src/main/res/values/strings.xml | 6 + 9 files changed, 550 insertions(+), 145 deletions(-) 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 index da0f498201..c61d083751 100644 --- 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 @@ -1,7 +1,11 @@ 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 @@ -32,4 +36,16 @@ interface GroupDao { @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_PARENT_UID = (: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/entities/GroupEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt index cd9a1aa907..fbdfbc6d4c 100644 --- 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 @@ -14,6 +14,7 @@ import com.google.gson.annotations.SerializedName import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.entities.KeyMapEntity.Companion.NAME_CONSTRAINT_LIST import kotlinx.parcelize.Parcelize +import java.util.UUID @Entity( tableName = GroupDao.TABLE_NAME, @@ -32,7 +33,7 @@ data class GroupEntity( @PrimaryKey @ColumnInfo(name = GroupDao.KEY_UID) @SerializedName(NAME_UID) - val uid: String, + val uid: String = UUID.randomUUID().toString(), @ColumnInfo(name = GroupDao.KEY_NAME) @SerializedName(NAME_NAME) 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 index 96e272385e..d5f08c016f 100644 --- 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 @@ -8,12 +8,18 @@ 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.flowOn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext interface GroupRepository { fun getKeyMapsByGroup(groupUid: String): Flow suspend fun getGroup(uid: String): GroupEntity? 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( @@ -22,18 +28,38 @@ class RoomGroupRepository( private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : GroupRepository { override fun getKeyMapsByGroup(groupUid: String): Flow { - return dao.getKeyMapsByGroup(groupUid) + return dao.getKeyMapsByGroup(groupUid).flowOn(dispatchers.io()) } override suspend fun getGroup(uid: String): GroupEntity? { - return dao.getById(uid) + return withContext(dispatchers.io()) { dao.getById(uid) } } override fun getGroupsByParent(uid: String?): Flow> { - return dao.getGroupsByParent(uid) + return dao.getGroupsByParent(uid).flowOn(dispatchers.io()) } override fun getGroupWithSubGroups(uid: String): Flow { - return dao.getGroupWithSubGroups(uid) + 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/home/HomeKeyMapListScreen.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt index 883a369a0d..0fc8e7371f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -199,6 +199,9 @@ fun HomeKeyMapListScreen( } }, onSelectAllClick = viewModel::onSelectAllClick, + onNewGroupClick = viewModel::onNewGroupClick, + onRenameGroupClick = viewModel::onRenameGroupClick, + isEditingGroupName = viewModel.isEditingGroupName, ) }, selectionBottomSheet = { 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 index c80a9b3913..a551e4d37b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -16,15 +16,20 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +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.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 @@ -42,23 +47,32 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R @@ -74,6 +88,7 @@ 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.icons.Import import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons +import kotlinx.coroutines.launch @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -90,6 +105,49 @@ fun KeyMapAppBar( onBackClick: () -> Unit = {}, onSelectAllClick: () -> Unit = {}, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + onNewGroupClick: () -> Unit = {}, + onRenameGroupClick: suspend (String) -> Boolean = { true }, + isEditingGroupName: Boolean = false, +) { + BackHandler(onBack = onBackClick) + + Column { + RootGroupAppBar( + scrollBehavior, + state, + onTogglePausedClick, + onSortClick, + onBackClick, + onFixWarningClick, + onNewGroupClick = onNewGroupClick, + actions = { + AnimatedVisibility(!isEditingGroupName) { + AppBarActions( + state, + onSelectAllClick, + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + ) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RootGroupAppBar( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.RootGroup, + scrollBehavior: TopAppBarScrollBehavior, + onTogglePausedClick: () -> Unit, + onSortClick: () -> Unit, + onFixWarningClick: (String) -> Unit, + onNewGroupClick: () -> Unit, + actions: @Composable RowScope.() -> Unit, ) { // This is taken from the AppBar color code. val colorTransitionFraction by @@ -101,17 +159,13 @@ fun KeyMapAppBar( } } - val appBarColors = if (state is KeyMapAppBarState.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 appBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) val appBarContainerColor by animateColorAsState( targetValue = lerp( @@ -122,136 +176,290 @@ fun KeyMapAppBar( animationSpec = spring(stiffness = Spring.StiffnessMediumLow), ) - var expandedDropdown by rememberSaveable { mutableStateOf(false) } - - BackHandler(onBack = onBackClick) - - // TODO to solve the problem of the key map list jumping up when showing the bottom sheet: - // Always show it behind the bottom nav bar and add the necessary padding at the end of the list. The bottom sheet - // will then just replace the bottom sheet. - - Column { + Column(modifier) { CenterAlignedTopAppBar( scrollBehavior = scrollBehavior, title = { - when (state) { - is KeyMapAppBarState.RootGroup -> AppBarStatus( - state = state, - onTogglePausedClick = onTogglePausedClick, - ) - - is KeyMapAppBarState.Selecting -> SelectedText(selectionCount = state.selectionCount) - is KeyMapAppBarState.ChildGroup -> TODO() - } + AppBarStatus( + isPaused = state.isPaused, + warnings = state.warnings, + onTogglePausedClick = onTogglePausedClick, + ) }, navigationIcon = { - AnimatedContent(state is KeyMapAppBarState.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(state is KeyMapAppBarState.Selecting) { isSelecting -> - if (isSelecting && state is KeyMapAppBarState.Selecting) { - 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) - } - } 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), - ) - } - - AppBarDropdownMenu( - expanded = expandedDropdown, - onSettingsClick = { - expandedDropdown = false - onSettingsClick() - }, - onAboutClick = { - expandedDropdown = false - onAboutClick() - }, - onExportClick = { - expandedDropdown = false - onExportClick() - }, - onImportClick = { - expandedDropdown = false - onImportClick() - }, - onDismissRequest = { expandedDropdown = false }, - ) - } - } + IconButton(onClick = onSortClick) { + Icon( + Icons.AutoMirrored.Rounded.Sort, + contentDescription = stringResource(R.string.home_app_bar_sort), + ) } }, + actions = actions, colors = appBarColors, ) - Column { - AnimatedVisibility( - visible = state is KeyMapAppBarState.RootGroup && state.warnings.isNotEmpty(), + 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(start = 8.dp, end = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + groups = state.subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = {}, + ) + } + } +} + +@Composable +private fun ChildGroupAppBar( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.ChildGroup, + onBackClick: () -> Unit, + onNewGroupClick: () -> Unit = {}, + onRenameGroupClick: suspend (String) -> Boolean = { true }, + isEditingGroupName: Boolean = false, + actions: @Composable RowScope.() -> Unit, +) { + Row(modifier) { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_app_bar_pop_group), + ) + } + GroupNameRow( + modifier = Modifier, + name = state.groupName, + onRenameClick = onRenameGroupClick, + isEditing = isEditingGroupName, + ) + + AnimatedVisibility(!isEditingGroupName) { + actions() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectingAppBar( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.Selecting, + onBackClick: () -> 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 = { + }, + colors = appBarColors, + ) + + Column { + AnimatedVisibility( + visible = state is KeyMapAppBarState.RootGroup && 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, + ) + } + } + + AnimatedVisibility( + visible = state !is KeyMapAppBarState.Selecting && !isEditingGroupName, + ) { + val subGroups = when (state) { + is KeyMapAppBarState.ChildGroup -> state.subGroups + is KeyMapAppBarState.RootGroup -> state.subGroups + is KeyMapAppBarState.Selecting -> emptyList() + } + + Surface(color = appBarContainerColor) { + GroupRow( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = {}, + ) + } + } + } +} + +@Composable +private fun AppBarActions( + state: KeyMapAppBarState, + onSelectAllClick: () -> Unit, + onHelpClick: () -> Unit, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + onExportClick: () -> Unit, + onImportClick: () -> Unit, +) { + var expandedDropdown by rememberSaveable { mutableStateOf(false) } + + AnimatedContent(state is KeyMapAppBarState.Selecting) { isSelecting -> + if (isSelecting && state is KeyMapAppBarState.Selecting) { + OutlinedButton( + modifier = Modifier.padding(horizontal = 8.dp), + onClick = onSelectAllClick, ) { - // 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, - ) + val text = if (state.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), + ) + } - AnimatedVisibility( - visible = state !is KeyMapAppBarState.Selecting, - ) { - val subGroups = when (state) { - is KeyMapAppBarState.ChildGroup -> state.subGroups - is KeyMapAppBarState.RootGroup -> state.subGroups - is KeyMapAppBarState.Selecting -> emptyList() + IconButton(onClick = { expandedDropdown = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.home_app_bar_more), + ) } - Surface(color = appBarContainerColor) { - GroupRow( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - groups = subGroups, - onNewGroupClick = {}, - onGroupClick = {}, + AppBarDropdownMenu( + expanded = expandedDropdown, + onSettingsClick = { + expandedDropdown = false + onSettingsClick() + }, + onAboutClick = { + expandedDropdown = false + onAboutClick() + }, + onExportClick = { + expandedDropdown = false + onExportClick() + }, + onImportClick = { + expandedDropdown = false + onImportClick() + }, + onDismissRequest = { expandedDropdown = false }, + ) + } + } + } +} + +@Composable +private fun GroupNameRow( + modifier: Modifier = Modifier, + name: String, + onRenameClick: suspend (String) -> Boolean = { true }, + isEditing: Boolean, +) { + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var newName by rememberSaveable { mutableStateOf(name) } + var error: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(isEditing) { + focusRequester.requestFocus() + } + + val uniqueErrorText = stringResource(R.string.home_app_bar_group_name_unique_error) + + fun onDoneClick(name: String) { + scope.launch { + val success = onRenameClick(name) + if (!success) { + error = uniqueErrorText + } + } + } + + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = newName, + placeholder = { Text(name) }, + onValueChange = { + error = null + newName = it + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + showKeyboardOnFocus = true, + ), + colors = if (isEditing) { + OutlinedTextFieldDefaults.colors() + } else { + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + disabledTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + keyboardActions = KeyboardActions(onDone = { onDoneClick(newName) }), + isError = error != null, + supportingText = if (error == null) { + null + } else { + { Text(error!!) } + }, + enabled = isEditing, + ) + + AnimatedContent(isEditing) { isEditing -> + if (isEditing) { + IconButton(onClick = { onDoneClick(newName) }) { + Icon( + Icons.Rounded.Done, + contentDescription = stringResource(R.string.home_app_bar_save_group_name), + ) + } + } else { + IconButton(onClick = { + focusRequester.freeFocus() + focusRequester.requestFocus() + }) { + Icon( + Icons.Rounded.Edit, + contentDescription = stringResource(R.string.home_app_bar_edit_group_name), ) } } @@ -261,11 +469,12 @@ fun KeyMapAppBar( @Composable private fun AppBarStatus( - state: KeyMapAppBarState.RootGroup, + isPaused: Boolean, + warnings: List, onTogglePausedClick: () -> Unit, ) { val pausedButtonContainerColor by animateColorAsState( - targetValue = if (state.isPaused || state.warnings.isNotEmpty()) { + targetValue = if (isPaused || warnings.isNotEmpty()) { MaterialTheme.colorScheme.errorContainer } else { LocalCustomColorsPalette.current.greenContainer @@ -273,7 +482,7 @@ private fun AppBarStatus( ) val pausedButtonContentColor by animateColorAsState( - targetValue = if (state.isPaused || state.warnings.isNotEmpty()) { + targetValue = if (isPaused || warnings.isNotEmpty()) { MaterialTheme.colorScheme.onErrorContainer } else { LocalCustomColorsPalette.current.onGreenContainer @@ -292,15 +501,15 @@ private fun AppBarStatus( val buttonIcon: ImageVector val buttonText: String - if (state.isPaused) { + if (isPaused) { buttonIcon = Icons.Rounded.PauseCircleOutline buttonText = stringResource(R.string.home_app_bar_status_paused) - } else if (state.warnings.isNotEmpty()) { + } else if (warnings.isNotEmpty()) { buttonIcon = Icons.Rounded.ErrorOutline buttonText = pluralStringResource( R.plurals.home_app_bar_status_warnings, - state.warnings.size, - state.warnings.size, + warnings.size, + warnings.size, ) } else { buttonIcon = Icons.Rounded.PlayCircleOutline @@ -444,7 +653,32 @@ private fun KeyMapsChildGroupPreview() { constraintMode = ConstraintMode.AND, ) KeyMapperTheme { - KeyMapAppBar(state = state) + KeyMapAppBar(state = state, isEditingGroupName = false) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun KeyMapsChildGroupEditingPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "My group", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + constraintMode = ConstraintMode.AND, + ) + + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme { + KeyMapAppBar( + state = state, + isEditingGroupName = true, + ) } } @@ -492,7 +726,11 @@ private fun HomeStateWarningsPreview() { ) val state = - KeyMapAppBarState.RootGroup(subGroups = emptyList(), warnings = warnings, isPaused = true) + KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = warnings, + isPaused = true, + ) KeyMapperTheme { KeyMapAppBar(state = state) } @@ -514,7 +752,11 @@ private fun HomeStateWarningsDarkPreview() { ) val state = - KeyMapAppBarState.RootGroup(subGroups = emptyList(), warnings = warnings, isPaused = true) + KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = warnings, + isPaused = true, + ) KeyMapperTheme { KeyMapAppBar(state = 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 f32465a24b..eb7836a17b 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 @@ -181,6 +181,13 @@ class KeyMapListViewModel( } } + /** + * 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 sortedKeyMapsFlow = combine( keyMapGroupStateFlow.map { it.keyMaps }.distinctUntilChanged(), @@ -604,11 +611,44 @@ class KeyMapListViewModel( } fun onBackClick(): Boolean { - if (multiSelectProvider.state.value is SelectionState.Selecting) { - multiSelectProvider.stopSelecting() - return true - } else { - return false + when { + multiSelectProvider.state.value is SelectionState.Selecting -> { + multiSelectProvider.stopSelecting() + return true + } + + state.value.appBarState is KeyMapAppBarState.ChildGroup -> { + if (isEditingGroupName && isNewGroup) { + listKeyMaps.deleteGroup() + } 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 onNewGroupClick() { + coroutineScope.launch { + listKeyMaps.newGroup() + isNewGroup = true + isEditingGroupName = true } } 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 42bb2b8f51..d4e3a20272 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,8 +1,11 @@ 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.data.entities.GroupEntity import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.groups.GroupEntityMapper @@ -12,6 +15,7 @@ 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 @@ -34,12 +38,51 @@ class ListKeyMapsUseCaseImpl( 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) + 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 } + } + + override fun deleteGroup() { + groupUid.value?.also { groupUid -> + this.groupUid.value = null + 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 + } + override suspend fun openGroup(uid: String) { // Check if the group exists. val group = groupRepository.getGroup(uid) ?: return @@ -60,6 +103,29 @@ class ListKeyMapsUseCaseImpl( } } + private suspend fun ensureUniqueName( + entity: GroupEntity, + block: suspend (entity: GroupEntity) -> Unit, + ): GroupEntity { + var group = entity + var count = 0 + + while (true) { + // Insert must be suspending so we only update the layout uid once the layout + // has been saved. + try { + block(group) + break + } catch (_: SQLiteConstraintException) { + // If the name already exists try creating it with a new name. + group = group.copy(name = "${entity.name} (${count + 1})") + count++ + } + } + + return group + } + @OptIn(ExperimentalCoroutinesApi::class) private val group: Flow = groupUid.flatMapLatest { groupUid -> if (groupUid == null) { @@ -155,8 +221,11 @@ class ListKeyMapsUseCaseImpl( interface ListKeyMapsUseCase : DisplayKeyMapUseCase { val keyMapGroup: Flow + suspend fun newGroup() suspend fun openGroup(uid: String) suspend fun popGroup() + fun deleteGroup() + suspend fun renameGroup(name: String): Boolean fun deleteKeyMap(vararg uid: String) fun enableKeyMap(vararg uid: String) 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 76bf998a5f..d0d5cbb785 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 @@ -142,6 +142,7 @@ object Inject { ServiceLocator.floatingButtonRepository(ctx), ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), + ServiceLocator.resourceProvider(ctx), UseCases.displayKeyMap(ctx), ), UseCases.createKeymapShortcut(ctx), @@ -155,6 +156,7 @@ object Inject { ServiceLocator.floatingButtonRepository(ctx), ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), + ServiceLocator.resourceProvider(ctx), UseCases.displayKeyMap(ctx), ), UseCases.pauseMappings(ctx), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04d6e68128..ba39fd6732 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1310,6 +1310,7 @@ Select all Deselect all Stop selecting + Go up a group Paused 1 warning @@ -1423,4 +1424,9 @@ Choose a constraint This key map will only run if: + + Untitled group + Edit group name + Save group name + Name must be unique! From 35336fd85909369ec121fe73a40db2ec184b74fc Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 17:14:13 -0600 Subject: [PATCH 11/94] fix: turn off flashlight when decreasing all the way down --- CHANGELOG.md | 6 ++++++ .../sds100/keymapper/system/camera/AndroidCameraAdapter.kt | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46339c06db..00cdc4e74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [3.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.3) + +## Changed + +- Turn off flashlight when using decrease brightness action. + ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) #### 27 March 2025 diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index dbf4804dac..1bb7451cad 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -179,7 +179,11 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { .toInt() .coerceIn(1, maxStrength) - cameraManager.turnOnTorchWithStrengthLevel(cameraId, newStrength) + if (newStrength == 1 && currentStrength == 1) { + cameraManager.setTorchMode(cameraId, false) + } else { + cameraManager.turnOnTorchWithStrengthLevel(cameraId, newStrength) + } } return Success(Unit) From c660a8f3102ed9ea706a065d0acdbf6c2451231b Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 17:14:26 -0600 Subject: [PATCH 12/94] fix: turn off flashlight when decreasing all the way down --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00cdc4e74f..8563751065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [3.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.3) +#### TO BE RELEASED + ## Changed - Turn off flashlight when using decrease brightness action. From 96824f58fe5de63400e920eeb1c1600b0d54dc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Wed, 26 Mar 2025 00:06:31 +0100 Subject: [PATCH 13/94] #1276 refactor: display change to scanCode fallback --- .../java/io/github/sds100/keymapper/data/Keys.kt | 2 ++ .../keymaps/trigger/BaseConfigTriggerViewModel.kt | 14 ++++++++++++++ .../keymapper/onboarding/OnboardingUseCase.kt | 5 +++++ .../system/inputevents/InputEventUtils.kt | 11 ++++++++++- app/src/main/res/values/strings.xml | 1 + 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt index ae579aede1..06ca3f1fec 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -52,6 +52,8 @@ object Keys { booleanPreferencesKey("key_shown_parallel_trigger_order_warning") val shownSequenceTriggerExplanation = booleanPreferencesKey("key_shown_sequence_trigger_explanation_dialog") + val shownKeyCodeToScanCodeTriggerExplanation = + booleanPreferencesKey("key_shown_keycode_to_scancode_trigger_explanation_dialog") val lastInstalledVersionCodeHomeScreen = intPreferencesKey("last_installed_version_home_screen") val lastInstalledVersionCodeBackground = diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index f65d21ee6d..e1fceadb92 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -456,6 +456,20 @@ abstract class BaseConfigTriggerViewModel( // need to be dismissed before it is added. config.addKeyCodeTriggerKey(key.keyCode, key.device, key.detectionSource) + if (key.keyCode >= InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET) { + if (onboarding.shownKeyCodeToScanCodeTriggerExplanation) { + return + } + + val dialog = PopupUi.Ok( + message = getString(R.string.dialog_message_keycode_to_scancode_trigger_explanation), + ) + + showPopup("keycode_to_scancode_message", dialog) + + onboarding.shownKeyCodeToScanCodeTriggerExplanation = true + } + if (key.keyCode == KeyEvent.KEYCODE_CAPS_LOCK) { val dialog = PopupUi.Ok( message = getString(R.string.dialog_message_enable_physical_keyboard_caps_lock_a_keyboard_layout), diff --git a/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt index bc12b49c18..73b90c0118 100644 --- a/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt @@ -65,6 +65,10 @@ class OnboardingUseCaseImpl( Keys.shownSequenceTriggerExplanation, false, ) + override var shownKeyCodeToScanCodeTriggerExplanation by PrefDelegate( + Keys.shownKeyCodeToScanCodeTriggerExplanation, + false, + ) override val showWhatsNew = get(Keys.lastInstalledVersionCodeHomeScreen) .map { (it ?: -1) < Constants.VERSION_CODE } @@ -167,6 +171,7 @@ interface OnboardingUseCase { var shownParallelTriggerOrderExplanation: Boolean var shownSequenceTriggerExplanation: Boolean + var shownKeyCodeToScanCodeTriggerExplanation: Boolean val showFloatingButtonFeatureNotification: Flow fun showedFloatingButtonFeatureNotification() diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt index 385250d437..ae5813cbac 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt @@ -686,12 +686,21 @@ object InputEventUtils { KeyEvent.KEYCODE_FUNCTION, ) + /** + * Used for keyCode to scanCode fallback to go past possible keyCode values + */ + const val KEYCODE_TO_SCANCODE_OFFSET: Int = 1000 + /** * Create a text representation of a key event. E.g if the control key was pressed, * "Ctrl" will be returned */ fun keyCodeToString(keyCode: Int): String = NON_CHARACTER_KEY_LABELS[keyCode].let { - it ?: "unknown keycode $keyCode" + if (keyCode >= KEYCODE_TO_SCANCODE_OFFSET) { + "scancode $keyCode" + } else { + it ?: "unknown keycode $keyCode" + } } fun isModifierKey(keyCode: Int): Boolean = keyCode in MODIFIER_KEYCODES diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9995eaaecc..bf016a9df9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -415,6 +415,7 @@ Your device doesn\'t seem to have an accessibility services settings page. Tap \"guide\" to read the online guide that explains how to fix this. The keys need to be listed from top to bottom in the order that they will be held down. A \"sequence\" trigger has a timeout unlike parallel triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. + The pressed button was not recognized by the input system. In the past Key Mapper detected such buttons as one and the same. Currently the app tries to distinguish the button based on the scan code, which should be more unique. However, this is a makeshift incomplete solution, which doesn\'t guarantee uniqueness. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Change location or turn off automatic back up? Screen on/off constraints will only work if you have turned on the \"detect trigger when screen is off\" key map option. This option will only show for some keys (e.g volume buttons) and if you are rooted. See a list of supported keys on the Help page. From a35d738dfb8c24ac22e7da647052e0ac89554cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Wed, 19 Mar 2025 04:43:54 +0100 Subject: [PATCH 14/94] #1276 refactor: fallback to scanCode when keyCode unknown --- .../BaseAccessibilityServiceController.kt | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 1c6981df4b..52b75ec5af 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 @@ -24,6 +24,7 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyEventDetectionSour import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsController import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCase import io.github.sds100.keymapper.system.devices.DevicesAdapter +import io.github.sds100.keymapper.system.inputevents.InputEventUtils import io.github.sds100.keymapper.system.inputevents.MyKeyEvent import io.github.sds100.keymapper.system.inputevents.MyMotionEvent import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter @@ -296,6 +297,22 @@ abstract class BaseAccessibilityServiceController( open fun onConfigurationChanged(newConfig: Configuration) { } + /** + * Returns an MyKeyEvent which is either the same or more unique + */ + private fun getUniqueEvent(event: MyKeyEvent): MyKeyEvent { + // Guard to ignore processing when not applicable + if (event.keyCode != KeyEvent.KEYCODE_UNKNOWN) return event + + val eventProxy = event.copy( + // Fallback to scanCode when keyCode is unknown as it's typically more unique + // Add offset to go past possible keyCode values + keyCode = event.scanCode + InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET, + ) + + return eventProxy + } + fun onKeyEvent( event: MyKeyEvent, detectionSource: KeyEventDetectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, @@ -305,11 +322,14 @@ abstract class BaseAccessibilityServiceController( if (recordingTrigger) { if (event.action == KeyEvent.ACTION_DOWN) { Timber.d("Recorded key ${KeyEvent.keyCodeToString(event.keyCode)}, $detailedLogInfo") + + val uniqueEvent: MyKeyEvent = getUniqueEvent(event) + coroutineScope.launch { outputEvents.emit( ServiceEvent.RecordedTriggerKey( - event.keyCode, - event.device, + uniqueEvent.keyCode, + uniqueEvent.device, detectionSource, ), ) @@ -327,16 +347,17 @@ abstract class BaseAccessibilityServiceController( } else { try { var consume: Boolean + val uniqueEvent: MyKeyEvent = getUniqueEvent(event) - consume = keyMapController.onKeyEvent(event) + consume = keyMapController.onKeyEvent(uniqueEvent) if (!consume) { - consume = rerouteKeyEventsController.onKeyEvent(event) + consume = rerouteKeyEventsController.onKeyEvent(uniqueEvent) } - when (event.action) { - KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(event.keyCode)} - consumed: $consume, $detailedLogInfo") - KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(event.keyCode)} - consumed: $consume, $detailedLogInfo") + when (uniqueEvent.action) { + KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(uniqueEvent.keyCode)} - consumed: $consume, $detailedLogInfo") + KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(uniqueEvent.keyCode)} - consumed: $consume, $detailedLogInfo") } return consume From 89e6ec60ed8778450b104f80e8f658c6f8c81cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Wed, 19 Mar 2025 13:15:20 +0100 Subject: [PATCH 15/94] chore: limit ci steps to root repo --- .github/workflows/crowdin-actions.yml | 1 + .github/workflows/testing.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/crowdin-actions.yml b/.github/workflows/crowdin-actions.yml index bdc008c7c9..e0f46f3c7f 100644 --- a/.github/workflows/crowdin-actions.yml +++ b/.github/workflows/crowdin-actions.yml @@ -20,6 +20,7 @@ jobs: - name: crowdin action uses: crowdin/github-action@v2 + if: github.event.repository.fork == false with: upload_sources: true upload_translations: false diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 754ebd0179..7bef735539 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -96,6 +96,7 @@ jobs: ruby-version: '3.3' - name: Create debug keystore + if: github.event.repository.fork == false env: CI_KEYSTORE: ${{ secrets.CI_KEYSTORE }} run: | @@ -123,6 +124,7 @@ jobs: - name: Upload to Discord uses: sinshutu/upload-to-discord@v2.0.0 + if: github.event.repository.fork == false env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: @@ -130,7 +132,7 @@ jobs: - name: Report build status to Discord uses: sarisia/actions-status-discord@v1 - if: failure() + if: github.event.repository.fork == false && failure() with: title: "Build apk" webhook: ${{ secrets.DISCORD_BUILD_STATUS_WEBHOOK }} \ No newline at end of file From 00dc6d3225684cf1cd2638d4c582ee11b158a6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Tue, 25 Mar 2025 22:27:46 +0100 Subject: [PATCH 16/94] chore: make configField optional --- app/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 82613d7e1e..c5a30e661a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,11 +98,14 @@ android { dimension "pro" File file = rootProject.file("local.properties") + String keyName = "REVENUECAT_API_KEY" if (file.exists()) { def localProperties = new Properties() localProperties.load(new FileInputStream(file)) - buildConfigField("String", "REVENUECAT_API_KEY", localProperties["REVENUECAT_API_KEY"]) + if (localProperties.hasProperty(keyName)) { + buildConfigField("String", keyName, localProperties[keyName]) + } } } } From 51de39d7f7cd24a3e5afd39abc584ebef996398a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Wed, 26 Mar 2025 13:14:54 +0100 Subject: [PATCH 17/94] docs: fix typos --- docs/includes/action-type-list.md | 2 +- docs/includes/configuring-constraints.md | 2 +- docs/user-guide/actions.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/includes/action-type-list.md b/docs/includes/action-type-list.md index a08a63c144..f2fe240cb6 100644 --- a/docs/includes/action-type-list.md +++ b/docs/includes/action-type-list.md @@ -6,7 +6,7 @@ Tab | Description | | [System](../user-guide/actions#system) | Choose a system operation (such as toggling Bluetooth, opening the home menu, toggling flashlight) | | [Key](../user-guide/actions#key) | An alternative way to choose a key press action, by pressing the key that you want to map to. | | [Tap screen (2.1.0+)](../user-guide/actions#tap-screen-210) | Emulate a screen tap at a specific location on your screen. | -| [Key event (2.1.0+)](../user-guide/actions#key-event-210) | Emulate a key press from a specifc connected device. | +| [Key event (2.1.0+)](../user-guide/actions#key-event-210) | Emulate a key press from a specific connected device. | | [Text](../user-guide/actions#text) | Emulate typing a string. | | [Intent (2.3.0+)](../user-guide/actions#intent-230) | See [this page.](../user-guide/actions/#intent-230) | | [Phone call (2.3.0+)](../user-guide/actions#phone-call-230) | Call a telephone number. Network and carrier rates will apply. | diff --git a/docs/includes/configuring-constraints.md b/docs/includes/configuring-constraints.md index 1788995a89..3621436a9e 100644 --- a/docs/includes/configuring-constraints.md +++ b/docs/includes/configuring-constraints.md @@ -1,6 +1,6 @@ Constraints allow you to restrict your mappings to only work in some situations. -To add a constraint fron the 'Constraints and more' or 'Options' tab, tap 'Add constraint'. +To add a constraint from the 'Constraints and more' or 'Options' tab, tap 'Add constraint'. Go [here](/user-guide/constraints) to see how you can configure constraints. \ No newline at end of file diff --git a/docs/user-guide/actions.md b/docs/user-guide/actions.md index d99dc17012..e1e2e879f5 100644 --- a/docs/user-guide/actions.md +++ b/docs/user-guide/actions.md @@ -3,7 +3,7 @@ Launch an app. !!! warning "Extra permission on Xiaomi devices!" - See issue [#1370](https://github.com/keymapperorg/KeyMapper/issues/1370https://github.com/keymapperorg/KeyMapper/issues/1370). Xioami blocks apps from launching apps when they are in the background unless you give permission to "Display pop-up windows while running in the background" and "Display pop-up window". Follow these steps through the Settings app: Apps > Manage apps > Key Mapper App Settings > Other Permissions > Display pop-up windows while running in the background. + See issue [#1370](https://github.com/keymapperorg/KeyMapper/issues/1370). Xiaomi blocks apps from launching apps when they are in the background unless you give permission to "Display pop-up windows while running in the background" and "Display pop-up window". Follow these steps through the Settings app: Apps > Manage apps > Key Mapper App Settings > Other Permissions > Display pop-up windows while running in the background. ### Launch app shortcut @@ -24,7 +24,7 @@ Android restricts what apps can do with this so you won't be able to tap the scr ### Swipe screen (2.5.0+, Android 7.0+) -This will swipe from a start point to and end point on your screen. You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limitied due to your Android Version. +This will swipe from a start point to and end point on your screen. You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limited due to your Android Version. See: [getMaxStrokeCount](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxStrokeCount()) and [getMaxStrokeDuration](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxGestureDuration()) for more information. @@ -32,7 +32,7 @@ See: [getMaxStrokeCount](https://developer.android.com/reference/android/accessi This will simulate a pinch gesture from a start point to and end point on your screen. You can choose between *pinch in* and *pinch out* and also the "pinch distance" for the pinch gesture. This is the distance between the start and the end point. The higher the distance, the stronger the pinch gesture, so you may want to start with a lower value for the pinch with max. 100. Later on you can adjust this by your needs, **but** the endpoints will never be less than 0 due to android restrictions. -You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limitied due to your Android Version. +You can also setup the amount of "fingers" to simulate and the duration for the gesture, **but** this is limited due to your Android Version. See: [getMaxStrokeCount](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxStrokeCount()) and [getMaxStrokeDuration](https://developer.android.com/reference/android/accessibilityservice/GestureDescription#getMaxGestureDuration()) for more information. From 78643079aca3bc2eb79807bde9437fccf0272eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Thu, 27 Mar 2025 00:20:52 +0100 Subject: [PATCH 18/94] refactor: use MaxKeyCode for scanCode offset refactor: don't offset negative scanCode --- .../keymaps/trigger/BaseConfigTriggerViewModel.kt | 2 +- .../accessibility/BaseAccessibilityServiceController.kt | 9 ++++++++- .../keymapper/system/inputevents/InputEventUtils.kt | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index e1fceadb92..796fc626aa 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -456,7 +456,7 @@ abstract class BaseConfigTriggerViewModel( // need to be dismissed before it is added. config.addKeyCodeTriggerKey(key.keyCode, key.device, key.detectionSource) - if (key.keyCode >= InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET) { + if (key.keyCode >= InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET || key.keyCode < 0) { if (onboarding.shownKeyCodeToScanCodeTriggerExplanation) { return } 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 52b75ec5af..c56d83205e 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 @@ -304,10 +304,17 @@ abstract class BaseAccessibilityServiceController( // Guard to ignore processing when not applicable if (event.keyCode != KeyEvent.KEYCODE_UNKNOWN) return event + // Don't offset negative values + val scanCodeOffset: Int = if (event.scanCode >= 0) { + InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET + } else { + 0 + } + val eventProxy = event.copy( // Fallback to scanCode when keyCode is unknown as it's typically more unique // Add offset to go past possible keyCode values - keyCode = event.scanCode + InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET, + keyCode = event.scanCode + scanCodeOffset, ) return eventProxy diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt index ae5813cbac..e70541825a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt @@ -689,14 +689,15 @@ object InputEventUtils { /** * Used for keyCode to scanCode fallback to go past possible keyCode values */ - const val KEYCODE_TO_SCANCODE_OFFSET: Int = 1000 + val KEYCODE_TO_SCANCODE_OFFSET: Int + get() = KeyEvent.getMaxKeyCode() + 1 /** * Create a text representation of a key event. E.g if the control key was pressed, * "Ctrl" will be returned */ fun keyCodeToString(keyCode: Int): String = NON_CHARACTER_KEY_LABELS[keyCode].let { - if (keyCode >= KEYCODE_TO_SCANCODE_OFFSET) { + if (keyCode >= KEYCODE_TO_SCANCODE_OFFSET || keyCode < 0) { "scancode $keyCode" } else { it ?: "unknown keycode $keyCode" From 30f5d8b592cef743d6852faa6f63c84f73f93ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Krzy=C5=9Bk=C3=B3w?= Date: Thu, 27 Mar 2025 01:08:59 +0100 Subject: [PATCH 19/94] refactor: expand the dialog for scanCode fallback --- .../keymaps/trigger/BaseConfigTriggerViewModel.kt | 10 +++++++--- app/src/main/res/values/strings.xml | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index 796fc626aa..7116cf1550 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -461,13 +461,17 @@ abstract class BaseConfigTriggerViewModel( return } - val dialog = PopupUi.Ok( + val dialog = PopupUi.Dialog( + title = getString(R.string.dialog_title_keycode_to_scancode_trigger_explanation), message = getString(R.string.dialog_message_keycode_to_scancode_trigger_explanation), + positiveButtonText = getString(R.string.pos_understood), ) - showPopup("keycode_to_scancode_message", dialog) + val response = showPopup("keycode_to_scancode_message", dialog) - onboarding.shownKeyCodeToScanCodeTriggerExplanation = true + if (response == DialogResponse.POSITIVE) { + onboarding.shownKeyCodeToScanCodeTriggerExplanation = true + } } if (key.keyCode == KeyEvent.KEYCODE_CAPS_LOCK) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf016a9df9..c176fc418f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -415,7 +415,6 @@ Your device doesn\'t seem to have an accessibility services settings page. Tap \"guide\" to read the online guide that explains how to fix this. The keys need to be listed from top to bottom in the order that they will be held down. A \"sequence\" trigger has a timeout unlike parallel triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. - The pressed button was not recognized by the input system. In the past Key Mapper detected such buttons as one and the same. Currently the app tries to distinguish the button based on the scan code, which should be more unique. However, this is a makeshift incomplete solution, which doesn\'t guarantee uniqueness. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Change location or turn off automatic back up? Screen on/off constraints will only work if you have turned on the \"detect trigger when screen is off\" key map option. This option will only show for some keys (e.g volume buttons) and if you are rooted. See a list of supported keys on the Help page. @@ -493,6 +492,9 @@ Drag handle for %1$s Show example + Unrecognized key code + The pressed button was not recognized by the input system. In the past Key Mapper detected such buttons as one and the same. Currently the app tries to distinguish the button based on the scan code, which should be more unique. However, this is a makeshift incomplete solution, which doesn\'t guarantee uniqueness. + Done Guide Guide @@ -504,6 +506,7 @@ Apply Discard changes Save + Understood Turn off Cancel From f5e10aec4509a406d60d09efe3bd131bf0f821ec Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 18:58:15 -0600 Subject: [PATCH 20/94] fix: Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8563751065..221292c807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Turn off flashlight when using decrease brightness action. +## Bug fixes + +- Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. + ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) #### 27 March 2025 From 367323c355c35e7df8283d5f537e5f416614c2d4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 19:25:51 -0600 Subject: [PATCH 21/94] fix: Do not show floating buttons on the always-on display or when the display is "off". --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 221292c807..c4e3002b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## Bug fixes - Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. +- Do not show floating buttons on the always-on display or when the display is "off". ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) From 151617fb4aa40e0c84245855485a4a43cc83c73b Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 19:28:52 -0600 Subject: [PATCH 22/94] fix: Prompt to unlock device when tapping "Go back" on the floating menu. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e3002b1a..c046714331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. - Do not show floating buttons on the always-on display or when the display is "off". +- Prompt to unlock device when tapping "Go back" on the floating menu. ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) From 33380a6f07efe6023dce9b176dd2a4538d86ec4c Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 19:33:46 -0600 Subject: [PATCH 23/94] #1596 fix: Do not show the option for front flashlight if the device does not have one. --- CHANGELOG.md | 1 + .../actions/FlashlightActionBottomSheet.kt | 42 ++++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c046714331..852ec8d84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. - Do not show floating buttons on the always-on display or when the display is "off". - Prompt to unlock device when tapping "Go back" on the floating menu. +- #1596 Do not show the option for front flashlight if the device does not have one. ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt index df075c345e..5b019bf997 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt @@ -371,28 +371,30 @@ private fun FlashlightActionBottomSheet( Spacer(modifier = Modifier.height(8.dp)) - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.CameraFront, - text = stringResource(R.string.action_config_flashlight_choose_side), - ) - - Row(modifier = Modifier.padding(horizontal = 8.dp)) { - RadioButtonText( - modifier = Modifier, - text = stringResource(R.string.lens_front), - isSelected = selectedLens == CameraLens.FRONT, - onSelected = { onSelectLens(CameraLens.FRONT) }, - isEnabled = availableLenses.contains(CameraLens.FRONT), + if (availableLenses.size > 1) { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.CameraFront, + text = stringResource(R.string.action_config_flashlight_choose_side), ) - RadioButtonText( - modifier = Modifier, - text = stringResource(R.string.lens_back), - isSelected = selectedLens == CameraLens.BACK, - onSelected = { onSelectLens(CameraLens.BACK) }, - isEnabled = availableLenses.contains(CameraLens.BACK), - ) + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_front), + isSelected = selectedLens == CameraLens.FRONT, + onSelected = { onSelectLens(CameraLens.FRONT) }, + isEnabled = availableLenses.contains(CameraLens.FRONT), + ) + + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_back), + isSelected = selectedLens == CameraLens.BACK, + onSelected = { onSelectLens(CameraLens.BACK) }, + isEnabled = availableLenses.contains(CameraLens.BACK), + ) + } } Spacer(modifier = Modifier.height(8.dp)) From 2f23c94ee1f1db1ee142de4505d63298d032c4f0 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 20:02:55 -0600 Subject: [PATCH 24/94] only hide the buttons when the screen is off if ambient display is turned on --- .../system/display/AndroidDisplayAdapter.kt | 12 +++++++++++- .../keymapper/system/display/DisplayAdapter.kt | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 14e0e62377..1a9c2bfa98 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -57,7 +57,10 @@ class AndroidDisplayAdapter( } } - Intent.ACTION_SCREEN_OFF -> isScreenOn.update { false } + Intent.ACTION_SCREEN_OFF -> { + isAmbientDisplayEnabled.update { isAodEnabled() } + isScreenOn.update { false } + } } } } @@ -74,6 +77,9 @@ class AndroidDisplayAdapter( override val size: SizeKM get() = ctx.getRealDisplaySize() + override val isAmbientDisplayEnabled: MutableStateFlow = + MutableStateFlow(isAodEnabled()) + init { displayManager.registerDisplayListener( object : DisplayManager.DisplayListener { @@ -232,4 +238,8 @@ class AndroidDisplayAdapter( else -> throw Exception("Don't know how to convert $sdkRotation to Orientation") } + + private fun isAodEnabled(): Boolean { + return SettingsUtils.getSecureSetting(ctx, "doze_always_on") == 1 + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt index 25947af603..765797348f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt @@ -9,6 +9,7 @@ interface DisplayAdapter { val orientation: Flow val cachedOrientation: Orientation val size: SizeKM + val isAmbientDisplayEnabled: Flow fun isAutoRotateEnabled(): Boolean fun enableAutoRotate(): Result<*> From b025321fa2f68b11f21461b187216fbf787bb1ed Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 20:27:00 -0600 Subject: [PATCH 25/94] feat: animate floating buttons in and out --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 852ec8d84f..ff859d6635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## Changed - Turn off flashlight when using decrease brightness action. +- Animate floating buttons in and out. ## Bug fixes From c965a8e4a9423993f3040481d649442f1101950a Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 22:54:03 -0600 Subject: [PATCH 26/94] #1586 feat: customise floating button border and background opacity. --- CHANGELOG.md | 3 + .../15.json | 324 ++++++++++++++++++ .../sds100/keymapper/data/db/AppDatabase.kt | 14 +- .../data/db/dao/FloatingButtonDao.kt | 2 + .../data/entities/FloatingButtonEntity.kt | 18 + .../data/migration/AutoMigration14To15.kt | 5 + .../repositories/FloatingButtonRepository.kt | 2 + .../floating/FloatingButtonAppearance.kt | 4 + .../keymapper/floating/FloatingButtonData.kt | 8 + .../sds100/keymapper/util/StringUtils.kt | 4 + app/src/main/res/values/strings.xml | 2 + 11 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ff859d6635..b58a131a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ #### TO BE RELEASED +## Added +- #1586 🎨 Customise floating button border and background opacity. + ## Changed - Turn off flashlight when using decrease brightness action. diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json new file mode 100644 index 0000000000..e4da7a781f --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/15.json @@ -0,0 +1,324 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "eb7c2d3cb69e3eb4170ee2a3227c4805", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `folder_name` TEXT, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folderName", + "columnName": "folder_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb7c2d3cb69e3eb4170ee2a3227c4805')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 5260652290..1d6b4e1473 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.data.db import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters @@ -22,6 +23,7 @@ import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity 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.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -41,6 +43,10 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 entities = [KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::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), + ], ) @TypeConverters( ActionListTypeConverter::class, @@ -51,7 +57,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 14 + const val DATABASE_VERSION = 15 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -126,6 +132,12 @@ abstract class AppDatabase : RoomDatabase() { Migration13To14.migrateDatabase(database) } } + + val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("") + } + } } class RoomMigration11To12( diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt index ea402266f6..a1449f92ba 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt @@ -23,6 +23,8 @@ interface FloatingButtonDao { const val KEY_ORIENTATION = "orientation" const val KEY_DISPLAY_WIDTH = "display_width" const val KEY_DISPLAY_HEIGHT = "display_height" + const val KEY_BORDER_OPACITY = "border_opacity" + const val KEY_BACKGROUND_OPACITY = "background_opacity" } @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt index 80a0784969..233887712d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt @@ -6,9 +6,12 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableFloat import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName +import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_BACKGROUND_OPACITY +import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_BORDER_OPACITY import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_BUTTON_SIZE import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_DISPLAY_HEIGHT import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_DISPLAY_WIDTH @@ -70,6 +73,15 @@ data class FloatingButtonEntity( @ColumnInfo(name = KEY_DISPLAY_HEIGHT) @SerializedName(NAME_DISPLAY_HEIGHT) val displayHeight: Int, + + @ColumnInfo(name = KEY_BORDER_OPACITY) + @SerializedName(NAME_BORDER_OPACITY) + val borderOpacity: Float?, + + @ColumnInfo(name = KEY_BACKGROUND_OPACITY) + @SerializedName(NAME_BACKGROUND_OPACITY) + val backgroundOpacity: Float? = 1f, + ) : Parcelable { companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. @@ -82,6 +94,8 @@ data class FloatingButtonEntity( const val NAME_ORIENTATION = "orientation" const val NAME_DISPLAY_WIDTH = "displayWidth" const val NAME_DISPLAY_HEIGHT = "displayHeight" + const val NAME_BORDER_OPACITY = "border_opacity" + const val NAME_BACKGROUND_OPACITY = "background_opacity" val DESERIALIZER = jsonDeserializer { val uid by it.json.byString(NAME_UID) @@ -93,6 +107,8 @@ data class FloatingButtonEntity( val orientation by it.json.byString(NAME_ORIENTATION) val displayWidth by it.json.byInt(NAME_DISPLAY_WIDTH) val displayHeight by it.json.byInt(NAME_DISPLAY_HEIGHT) + val borderOpacity by it.json.byNullableFloat(NAME_BORDER_OPACITY) + val backgroundOpacity by it.json.byNullableFloat(NAME_BACKGROUND_OPACITY) FloatingButtonEntity( uid = uid, @@ -104,6 +120,8 @@ data class FloatingButtonEntity( orientation = orientation, displayWidth = displayWidth, displayHeight = displayHeight, + borderOpacity = borderOpacity, + backgroundOpacity = backgroundOpacity, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt new file mode 100644 index 0000000000..b823941134 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration14To15.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration14To15 : AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt index 8817218450..50080c1290 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/FloatingButtonRepository.kt @@ -46,6 +46,8 @@ class RoomFloatingButtonRepository( layoutUid = button.layoutUid, text = button.text, buttonSize = button.buttonSize, + borderOpacity = button.borderOpacity, + backgroundOpacity = button.backgroundOpacity, x = button.x, y = button.y, orientation = button.orientation, diff --git a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt index 8e4f5f6abd..973025ca96 100644 --- a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt +++ b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonAppearance.kt @@ -6,10 +6,14 @@ import kotlinx.serialization.Serializable data class FloatingButtonAppearance( val text: String = "", val size: Int = DEFAULT_SIZE_DP, + val borderOpacity: Float = DEFAULT_BORDER_OPACITY, + val backgroundOpacity: Float = DEFAULT_BACKGROUND_OPACITY, ) { companion object { const val MIN_SIZE_DP: Int = 20 const val DEFAULT_SIZE_DP: Int = 40 const val MAX_SIZE_DP: Int = 120 + const val DEFAULT_BACKGROUND_OPACITY = 0.5f + const val DEFAULT_BORDER_OPACITY = 1f } } diff --git a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt index 4de1da4ec9..f8a19fe376 100644 --- a/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt +++ b/app/src/main/java/io/github/sds100/keymapper/floating/FloatingButtonData.kt @@ -45,6 +45,8 @@ object FloatingButtonEntityMapper { return entity.copy( text = appearance.text, buttonSize = appearance.size, + borderOpacity = appearance.borderOpacity, + backgroundOpacity = appearance.backgroundOpacity, ) } @@ -66,6 +68,10 @@ object FloatingButtonEntityMapper { appearance = FloatingButtonAppearance( text = entity.text, size = entity.buttonSize, + borderOpacity = entity.borderOpacity + ?: FloatingButtonAppearance.DEFAULT_BORDER_OPACITY, + backgroundOpacity = entity.backgroundOpacity + ?: FloatingButtonAppearance.DEFAULT_BACKGROUND_OPACITY, ), location = Location( x = entity.x, @@ -82,6 +88,8 @@ object FloatingButtonEntityMapper { layoutUid = button.layoutUid, text = button.appearance.text, buttonSize = button.appearance.size, + borderOpacity = button.appearance.borderOpacity, + backgroundOpacity = button.appearance.backgroundOpacity, x = button.location.x, y = button.location.y, orientation = ConstantTypeConverters.ORIENTATION_MAP[button.location.orientation]!!, diff --git a/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt index ceaeca6af7..0a3335c89d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/StringUtils.kt @@ -43,3 +43,7 @@ fun String.getWordBoundaries(@IntRange(from = 0L) cursorPosition: Int): PairDelete Button text (Tip: use an emoji) Button size: + Border opacity: + Background opacity: Cancel Done The button must have text! From 1330b801c5aa93a99196769f9623c5da72899f13 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 28 Mar 2025 22:57:36 -0600 Subject: [PATCH 27/94] update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b58a131a25..edd5e40def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [3.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.3) +_See the changes from previous 3.0 Beta releases as well._ + #### TO BE RELEASED ## Added @@ -21,8 +23,6 @@ #### 27 March 2025 -_See the changes from previous 3.0 Beta releases as well._ - ## Added - #1560 Action to change flashlight brightness and also set a custom brightness when enabling the flashlight. From ee3bac129cad302ed7df96ebcf8d9eb80790bf59 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 01:44:45 -0600 Subject: [PATCH 28/94] #1598 fix: Do not allow changing flashlight brightness on devices that do not support it. --- CHANGELOG.md | 1 + .../sds100/keymapper/actions/FlashlightActionBottomSheet.kt | 4 +--- .../sds100/keymapper/system/camera/AndroidCameraAdapter.kt | 6 +++--- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edd5e40def..4757cdb0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _See the changes from previous 3.0 Beta releases as well._ - Do not show floating buttons on the always-on display or when the display is "off". - Prompt to unlock device when tapping "Go back" on the floating menu. - #1596 Do not show the option for front flashlight if the device does not have one. +- #1598 Do not allow changing flashlight brightness on devices that do not support it. ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt index 5b019bf997..1482dd5327 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/FlashlightActionBottomSheet.kt @@ -281,9 +281,7 @@ private fun ChangeFlashlightStrengthActionBottomSheet( onDismissRequest = onDismissRequest, title = stringResource(R.string.action_flashlight_change_strength), selectedLens = state.selectedLens, - availableLenses = state.lensData.entries - .filter { it.value.supportsVariableStrength } - .map { it.key }.toSet(), + availableLenses = state.lensData.entries.map { it.key }.toSet(), onSelectLens = onSelectLens, onDoneClick = onDoneClick, ) { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index 1bb7451cad..ab11382afc 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -72,7 +72,7 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { val maxFlashStrength = getCharacteristicForLens( lens, CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, - ) + ) ?: 1 val defaultFlashStrength = getCharacteristicForLens( lens, @@ -80,9 +80,9 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { ) return CameraFlashInfo( - supportsVariableStrength = true, + supportsVariableStrength = maxFlashStrength > 1, defaultStrength = defaultFlashStrength ?: 1, - maxStrength = maxFlashStrength ?: 1, + maxStrength = maxFlashStrength, ) } else { return CameraFlashInfo( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46b71e385..a2fc856edd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1401,7 +1401,7 @@ Max Test Requires Android 13 or newer. - This flash does not let you change the brightness. + This device does not let you change the brightness. Brightness change Unsupported From c3413189978cb16cbead6771749f3b239ed7cc94 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 01:45:16 -0600 Subject: [PATCH 29/94] chore: bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index f3894e9a39..e190652204 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=3.0.0-beta.3 -VERSION_CODE=84 +VERSION_CODE=85 VERSION_NUM=0 \ No newline at end of file From 639f29be25d23067367b9d12ab51be74d757fabb Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 02:09:48 -0600 Subject: [PATCH 30/94] fix tests --- .../java/io/github/sds100/keymapper/backup/BackupManager.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt index ce64fe44ca..b351ddc9e2 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 @@ -241,6 +241,9 @@ class BackupManagerImpl( // Do nothing because this just add the floating layouts table and indexes. JsonMigration(13, 14) { json -> json }, + + // Do nothing just added floating button entity columns + JsonMigration(14, 15) { json -> json }, ) if (keyMapListJsonArray != null) { From 8cae66f1b98ed51c13e8cfa8ef75241b838d778d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 10:54:42 -0600 Subject: [PATCH 31/94] actually change the accessibility service event timeout --- app/src/main/res/xml/config_accessibility_service.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/xml/config_accessibility_service.xml b/app/src/main/res/xml/config_accessibility_service.xml index 68715fbe04..425f3f1146 100644 --- a/app/src/main/res/xml/config_accessibility_service.xml +++ b/app/src/main/res/xml/config_accessibility_service.xml @@ -5,4 +5,4 @@ android:canRequestFingerprintGestures="true" android:canRetrieveWindowContent="true" android:description="@string/accessibility_service_explanation" - android:notificationTimeout="100" /> \ No newline at end of file + android:notificationTimeout="200" /> \ No newline at end of file From 1ad097152bb2bb6d4dbbfba8c4df9d91ee1f153e Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 10:58:00 -0600 Subject: [PATCH 32/94] CameraAdapter: change how turning off flashlight is checked --- .../sds100/keymapper/system/camera/AndroidCameraAdapter.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index ab11382afc..ac0e78b8de 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -177,9 +177,10 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { val newStrength = (currentStrength + (percent * maxStrength)) .toInt() - .coerceIn(1, maxStrength) + .coerceAtMost(maxStrength) - if (newStrength == 1 && currentStrength == 1) { + // If we want to go below the current strength then turn off the flashlight. + if (newStrength < 1) { cameraManager.setTorchMode(cameraId, false) } else { cameraManager.turnOnTorchWithStrengthLevel(cameraId, newStrength) From c4915c8c51a74b396ad37e37ca779a4d17caa5b6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 10:58:51 -0600 Subject: [PATCH 33/94] chore: add #1276 to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4757cdb0c0..ed173e5f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ _See the changes from previous 3.0 Beta releases as well._ ## Added - #1586 🎨 Customise floating button border and background opacity. +- #1276 Use key event scan code as fallback if the key code is unrecognized. ## Changed From e446fb2f3dd60564280e2f26d2d4408028105498 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 11:17:46 -0600 Subject: [PATCH 34/94] build.gradle: use containsKey instead of hasProperty to check for property --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index c5a30e661a..117a9d541a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -103,7 +103,7 @@ android { if (file.exists()) { def localProperties = new Properties() localProperties.load(new FileInputStream(file)) - if (localProperties.hasProperty(keyName)) { + if (localProperties.containsKey(keyName)) { buildConfigField("String", keyName, localProperties[keyName]) } } From 6dd128b5e80ea3261ec35e56104d410f753bd8bb Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 11:22:42 -0600 Subject: [PATCH 35/94] fix: do not turn on the flashlight when decreasing it while the flash is turned off --- .../sds100/keymapper/system/camera/AndroidCameraAdapter.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index ac0e78b8de..d2a41d3712 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -152,6 +152,11 @@ class AndroidCameraAdapter(context: Context) : CameraAdapter { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.TIRAMISU) } + // If the flash is disabled and it should be decreased then do nothing. + if (percent < 0 && isFlashEnabledMap.value[lens] == false) { + return Success(Unit) + } + try { val cameraId = getFlashlightCameraIdForLens(lens) From 141d6bbea0909ae46c3b58ce1e304c6993e25d96 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 12:02:14 -0600 Subject: [PATCH 36/94] #1276 fix: use 1000 as the key code scancode offset instead of getMaxKeycode so it is static and never changes value potentially breaking the trigger detection --- .../sds100/keymapper/system/inputevents/InputEventUtils.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt index e70541825a..d5b336eabc 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt @@ -689,8 +689,7 @@ object InputEventUtils { /** * Used for keyCode to scanCode fallback to go past possible keyCode values */ - val KEYCODE_TO_SCANCODE_OFFSET: Int - get() = KeyEvent.getMaxKeyCode() + 1 + val KEYCODE_TO_SCANCODE_OFFSET: Int = 1000 /** * Create a text representation of a key event. E.g if the control key was pressed, From 8871833e86ac82c61326a174d300770ee8dcdb54 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 13:32:44 -0600 Subject: [PATCH 37/94] chore: bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index e190652204..205a2cc2e7 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=3.0.0-beta.3 -VERSION_CODE=85 +VERSION_CODE=86 VERSION_NUM=0 \ No newline at end of file From de3aba38ff5338d46d878383c7b7e54c10e10f9c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 14:20:32 -0600 Subject: [PATCH 38/94] fix: increase padding on the side of the shortcuts row --- .../sds100/keymapper/actions/ActionsScreen.kt | 17 +++++++++++++++-- .../keymapper/constraints/ConstraintsScreen.kt | 6 ++++-- .../mappings/keymaps/trigger/TriggerScreen.kt | 8 +++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt index 9e617c4f98..ee4821ee43 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.FlashlightOn +import androidx.compose.material.icons.rounded.Pinch import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -148,7 +149,7 @@ private fun ActionsScreen( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = state.data.shortcuts, onClick = onClickShortcut, @@ -282,7 +283,9 @@ private fun ActionList( Spacer(Modifier.height(8.dp)) ShortcutRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), shortcuts = shortcuts, onClick = { onClickShortcut(it) }, ) @@ -308,6 +311,11 @@ private fun EmptyPreview() { strengthPercent = null, ), ), + ShortcutModel( + icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), + text = "Pinch in with 2 finger(s) on coordinates 5/4 with a pinch distance of 8px in 200ms", + data = ActionData.ConsumeKeyEvent, + ), ), ), ), @@ -350,6 +358,11 @@ private fun LoadedPreview() { strengthPercent = null, ), ), + ShortcutModel( + icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), + text = "Pinch in with 2 finger(s) on coordinates 5/4 with a pinch distance of 8px in 200ms", + data = ActionData.ConsumeKeyEvent, + ), ), isReorderingEnabled = true, ), diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt index d1c56cd317..bb2b78a354 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintsScreen.kt @@ -140,7 +140,7 @@ private fun ConstraintsScreen( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = state.data.shortcuts, onClick = onClickShortcut, @@ -284,7 +284,9 @@ private fun ConstraintList( Spacer(Modifier.height(8.dp)) ShortcutRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), shortcuts = shortcuts, onClick = { onClickShortcut(it) }, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt index 314560b7ec..dfa2911898 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt @@ -209,7 +209,7 @@ private fun TriggerScreenVertical( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = configState.shortcuts, onClick = onClickShortcut, @@ -313,7 +313,7 @@ private fun TriggerScreenHorizontal( ShortcutRow( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 32.dp) .fillMaxWidth(), shortcuts = configState.shortcuts, onClick = onClickShortcut, @@ -456,7 +456,9 @@ private fun TriggerList( Spacer(Modifier.height(8.dp)) ShortcutRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), shortcuts = shortcuts, onClick = { onClickShortcut(it) }, ) From 55bbcf97493a13fea413c2f55303e97c646afe06 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 14:28:21 -0600 Subject: [PATCH 39/94] fix: Omit "Back" from Back flashlight actions since most devices only have a back flashlight anyway. --- CHANGELOG.md | 1 + .../keymapper/actions/ActionUiHelper.kt | 95 +++++++++++++------ app/src/main/res/values/strings.xml | 25 +++-- 3 files changed, 81 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed173e5f96..f044243e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ _See the changes from previous 3.0 Beta releases as well._ - Turn off flashlight when using decrease brightness action. - Animate floating buttons in and out. +- Omit "Back" from Back flashlight actions since most devices only have a back flashlight anyway. ## Bug fixes diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt index 7ab1898c56..bf9a9d70d7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt @@ -7,7 +7,7 @@ import androidx.compose.material.icons.outlined.Android import io.github.sds100.keymapper.R import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.mappings.keymaps.KeyMap -import io.github.sds100.keymapper.system.camera.CameraLensUtils +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.devices.InputDeviceUtils import io.github.sds100.keymapper.system.display.OrientationUtils import io.github.sds100.keymapper.system.inputevents.InputEventUtils @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.system.volume.DndModeUtils import io.github.sds100.keymapper.system.volume.RingerModeUtils import io.github.sds100.keymapper.system.volume.VolumeStreamUtils import io.github.sds100.keymapper.util.handle +import io.github.sds100.keymapper.util.toPercentString import io.github.sds100.keymapper.util.ui.IconInfo import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.TintType @@ -241,53 +242,85 @@ class ActionUiHelper( ) is ActionData.Flashlight -> { - val lensString = getString(CameraLensUtils.getLabel(action.lens)) - when (action) { is ActionData.Flashlight.Toggle -> { if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - getString(R.string.action_toggle_flashlight_formatted, lensString) + if (action.lens == CameraLens.FRONT) { + getString(R.string.action_toggle_front_flashlight_formatted) + } else { + getString(R.string.action_toggle_flashlight_formatted) + } } else { - getString( - R.string.action_toggle_flashlight_with_strength, - arrayOf( - lensString, - (action.strengthPercent * 100).toInt(), - ), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_toggle_front_flashlight_with_strength, + action.strengthPercent.toPercentString(), + + ) + } else { + getString( + R.string.action_toggle_flashlight_with_strength, + action.strengthPercent.toPercentString(), + ) + } } } is ActionData.Flashlight.Enable -> { if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - getString(R.string.action_enable_flashlight_formatted, lensString) + if (action.lens == CameraLens.FRONT) { + getString(R.string.action_enable_front_flashlight_formatted) + } else { + getString(R.string.action_enable_flashlight_formatted) + } } else { - getString( - R.string.action_enable_flashlight_with_strength, - arrayOf( - lensString, - (action.strengthPercent * 100).toInt(), - ), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_enable_front_flashlight_with_strength, + action.strengthPercent.toPercentString(), + ) + } else { + getString( + R.string.action_enable_flashlight_with_strength, + action.strengthPercent.toPercentString(), + ) + } } } - is ActionData.Flashlight.Disable -> getString( - R.string.action_disable_flashlight_formatted, - lensString, - ) + is ActionData.Flashlight.Disable -> { + if (action.lens == CameraLens.FRONT) { + getString(R.string.action_disable_front_flashlight_formatted) + } else { + getString(R.string.action_disable_flashlight_formatted) + } + } is ActionData.Flashlight.ChangeStrength -> { if (action.percent > 0) { - getString( - R.string.action_flashlight_increase_strength_formatted, - arrayOf(lensString, (action.percent * 100).toInt()), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_front_flashlight_increase_strength_formatted, + action.percent.toPercentString(), + ) + } else { + getString( + R.string.action_flashlight_increase_strength_formatted, + action.percent.toPercentString(), + ) + } } else { - getString( - R.string.action_flashlight_decrease_strength_formatted, - arrayOf(lensString, (action.percent * 100).toInt()), - ) + if (action.lens == CameraLens.FRONT) { + getString( + R.string.action_front_flashlight_decrease_strength_formatted, + action.percent.toPercentString(), + ) + } else { + getString( + R.string.action_flashlight_decrease_strength_formatted, + action.percent.toPercentString(), + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc0c5311e3..65ef5b86ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -990,16 +990,23 @@ Enable flashlight Disable flashlight - Toggle %s flashlight - Toggle %s flashlight (%d%%) - Enable %s flashlight - Enable %s flashlight (%d\%%) - Disable %s flashlight - + Toggle flashlight + Toggle flashlight (%s) + Enable flashlight + Enable flashlight (%s) + Disable flashlight Change flashlight brightness - - Brighten %s flashlight %d\%% - Dim %s flashlight %d\%% + Brighten flashlight %s + Dim flashlight %s + + Toggle front flashlight + Toggle front flashlight (%s) + Enable front flashlight + Enable front flashlight (%s) + Disable front flashlight + Change front flashlight brightness + Brighten front flashlight %s + Dim front flashlight %s Enable NFC Disable NFC From 12c8f0754a18404aeac2b94893e76b1151351a4b Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 14:34:03 -0600 Subject: [PATCH 40/94] fix: Omit "Back" from flashlight constraints and do not ask for front or back if the device does not have more than one --- CHANGELOG.md | 3 ++- .../constraints/ChooseConstraintViewModel.kt | 12 +++++++---- .../constraints/ConstraintUiHelper.kt | 20 ++++++++++--------- .../constraints/CreateConstraintUseCase.kt | 19 +++++++++++++++++- .../io/github/sds100/keymapper/util/Inject.kt | 1 + app/src/main/res/values/strings.xml | 6 ++++-- 6 files changed, 44 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f044243e6d..16c66086b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ _See the changes from previous 3.0 Beta releases as well._ - Turn off flashlight when using decrease brightness action. - Animate floating buttons in and out. -- Omit "Back" from Back flashlight actions since most devices only have a back flashlight anyway. ## Bug fixes @@ -21,6 +20,8 @@ _See the changes from previous 3.0 Beta releases as well._ - Prompt to unlock device when tapping "Go back" on the floating menu. - #1596 Do not show the option for front flashlight if the device does not have one. - #1598 Do not allow changing flashlight brightness on devices that do not support it. +- Omit "Back" from Back flashlight actions and constraints since most devices only have a back flashlight anyway. +- Do not ask for which flashlight to use in constraints if the device only has one ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt index 91e372af00..5f1896e636 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import io.github.sds100.keymapper.R import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.system.camera.CameraLensUtils import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.containsQuery @@ -196,10 +197,13 @@ class ChooseConstraintViewModel( } private suspend fun chooseFlashlightLens(): CameraLens? { - val items = listOf( - CameraLens.FRONT to getString(R.string.lens_front), - CameraLens.BACK to getString(R.string.lens_back), - ) + val items = useCase.getFlashlightLenses().map { + it to getString(CameraLensUtils.getLabel(it)) + } + + if (items.size == 1) { + return items.first().first + } val dialog = PopupUi.SingleChoice(items) diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt index 6c31092e83..5b5c7548c6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.constraints import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Android import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.system.camera.CameraLensUtils +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.util.handle import io.github.sds100.keymapper.util.ui.ResourceProvider @@ -88,15 +88,17 @@ class ConstraintUiHelper( Constraint.ScreenOn -> getString(R.string.constraint_screen_on_description) - is Constraint.FlashlightOff -> getString( - R.string.constraint_flashlight_off_description, - getString(CameraLensUtils.getLabel(constraint.lens)), - ) + is Constraint.FlashlightOff -> if (constraint.lens == CameraLens.FRONT) { + getString(R.string.constraint_front_flashlight_off_description) + } else { + getString(R.string.constraint_flashlight_off_description) + } - is Constraint.FlashlightOn -> getString( - R.string.constraint_flashlight_on_description, - getString(CameraLensUtils.getLabel(constraint.lens)), - ) + is Constraint.FlashlightOn -> if (constraint.lens == CameraLens.FRONT) { + getString(R.string.constraint_front_flashlight_on_description) + } else { + getString(R.string.constraint_flashlight_on_description) + } is Constraint.WifiConnected -> { if (constraint.ssid == null) { diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt index 144847c02f..761af23fc4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt @@ -1,8 +1,11 @@ package io.github.sds100.keymapper.constraints +import android.content.pm.PackageManager import android.os.Build import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.camera.CameraAdapter +import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter @@ -19,15 +22,23 @@ class CreateConstraintUseCaseImpl( private val networkAdapter: NetworkAdapter, private val inputMethodAdapter: InputMethodAdapter, private val preferenceRepository: PreferenceRepository, + private val cameraAdapter: CameraAdapter, ) : CreateConstraintUseCase { override fun isSupported(constraint: ConstraintId): Error? { when (constraint) { - ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> + ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) } + if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && + cameraAdapter.getFlashInfo(CameraLens.FRONT) == null + ) { + return Error.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH) + } + } + ConstraintId.DEVICE_IS_LOCKED, ConstraintId.DEVICE_IS_UNLOCKED -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.LOLLIPOP_MR1) @@ -66,6 +77,10 @@ class CreateConstraintUseCaseImpl( override fun getSavedWifiSSIDs(): Flow> = preferenceRepository.get(Keys.savedWifiSSIDs) .map { it?.toList() ?: emptyList() } + + override fun getFlashlightLenses(): Set { + return CameraLens.entries.filter { cameraAdapter.getFlashInfo(it) != null }.toSet() + } } interface CreateConstraintUseCase { @@ -75,4 +90,6 @@ interface CreateConstraintUseCase { suspend fun saveWifiSSID(ssid: String) fun getSavedWifiSSIDs(): Flow> + + fun getFlashlightLenses(): Set } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index e42bb0b5a6..f2e9d3deb5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -72,6 +72,7 @@ object Inject { ServiceLocator.networkAdapter(ctx), ServiceLocator.inputMethodAdapter(ctx), ServiceLocator.settingsRepository(ctx), + ServiceLocator.cameraAdapter(ctx), ), ServiceLocator.resourceProvider(ctx), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65ef5b86ef..e450ad6c11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -227,8 +227,10 @@ %s is disconnected Screen is on Screen is off - %s flashlight is off - %s flashlight is on + Flashlight is off + Flashlight is on + Front flashlight is off + Front flashlight is on AND OR From af2a80584e7a367961c248b9c90d3a055d0a07af Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 15:38:17 -0600 Subject: [PATCH 41/94] WIP: child group app bar --- .../keymapper/home/HomeKeyMapListScreen.kt | 1 + .../sds100/keymapper/home/KeyMapAppBar.kt | 333 ++++++++++++------ .../mappings/keymaps/KeyMapListViewModel.kt | 4 + 3 files changed, 221 insertions(+), 117 deletions(-) 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 index 0fc8e7371f..ad9b75ea8e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -202,6 +202,7 @@ fun HomeKeyMapListScreen( onNewGroupClick = viewModel::onNewGroupClick, onRenameGroupClick = viewModel::onRenameGroupClick, isEditingGroupName = viewModel.isEditingGroupName, + onEditGroupNameClick = viewModel::onEditGroupNameClick, ) }, selectionBottomSheet = { 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 index a551e4d37b..cb3ffbcd31 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -13,15 +13,23 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction 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.fillMaxHeight 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.layout.widthIn +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -47,14 +55,17 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +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.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -72,7 +83,10 @@ 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.R @@ -93,6 +107,7 @@ import kotlinx.coroutines.launch @Composable @OptIn(ExperimentalMaterial3Api::class) fun KeyMapAppBar( + modifier: Modifier = Modifier, state: KeyMapAppBarState, onSettingsClick: () -> Unit = {}, onAboutClick: () -> Unit = {}, @@ -108,17 +123,18 @@ fun KeyMapAppBar( onNewGroupClick: () -> Unit = {}, onRenameGroupClick: suspend (String) -> Boolean = { true }, isEditingGroupName: Boolean = false, + onEditGroupNameClick: () -> Unit = {}, ) { BackHandler(onBack = onBackClick) - Column { - RootGroupAppBar( - scrollBehavior, - state, - onTogglePausedClick, - onSortClick, - onBackClick, - onFixWarningClick, + when (state) { + is KeyMapAppBarState.RootGroup -> RootGroupAppBar( + modifier = modifier, + state = state, + scrollBehavior = scrollBehavior, + onTogglePausedClick = onTogglePausedClick, + onSortClick = onSortClick, + onFixWarningClick = onFixWarningClick, onNewGroupClick = onNewGroupClick, actions = { AnimatedVisibility(!isEditingGroupName) { @@ -134,9 +150,51 @@ fun KeyMapAppBar( } }, ) + + is KeyMapAppBarState.Selecting -> SelectingAppBar( + modifier = modifier, + state = state, + onBackClick = onBackClick, + onSelectAllClick = onSelectAllClick, + ) + + is KeyMapAppBarState.ChildGroup -> ChildGroupAppBar( + modifier = modifier, + state = state, + onBackClick = onBackClick, + onEditGroupNameClick = onNewGroupClick, + onRenameGroupClick = onRenameGroupClick, + onEditClick = onEditGroupNameClick, + isEditingGroupName = isEditingGroupName, + actions = { + AnimatedVisibility(!isEditingGroupName) { + AppBarActions( + state, + onSelectAllClick, + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + ) + } + }, + ) } } +@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( @@ -159,13 +217,7 @@ private fun RootGroupAppBar( } } - val appBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) + val appBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors() val appBarContainerColor by animateColorAsState( targetValue = lerp( @@ -223,34 +275,44 @@ private fun RootGroupAppBar( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChildGroupAppBar( modifier: Modifier = Modifier, state: KeyMapAppBarState.ChildGroup, onBackClick: () -> Unit, - onNewGroupClick: () -> Unit = {}, + onEditGroupNameClick: () -> Unit = {}, + onEditClick: () -> Unit = {}, onRenameGroupClick: suspend (String) -> Boolean = { true }, isEditingGroupName: Boolean = false, actions: @Composable RowScope.() -> Unit, ) { - Row(modifier) { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.home_app_bar_pop_group), + TopAppBar( + modifier = modifier, + title = { + GroupNameRow( + modifier = Modifier, + name = state.groupName, + onRenameClick = onRenameGroupClick, + isEditing = isEditingGroupName, + onEditClick = onEditClick, ) - } - GroupNameRow( - modifier = Modifier, - name = state.groupName, - onRenameClick = onRenameGroupClick, - isEditing = isEditingGroupName, - ) - - AnimatedVisibility(!isEditingGroupName) { - actions() - } - } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_app_bar_pop_group), + ) + } + }, + actions = { + AnimatedVisibility(visible = !isEditingGroupName) { + actions() + } + }, + colors = primaryAppBarColors(), + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -259,9 +321,10 @@ private fun SelectingAppBar( modifier: Modifier = Modifier, state: KeyMapAppBarState.Selecting, onBackClick: () -> Unit, + onSelectAllClick: () -> Unit, ) { CenterAlignedTopAppBar( - modifier = Modifier, + modifier = modifier, title = { SelectedText(selectionCount = state.selectionCount) }, @@ -274,46 +337,20 @@ private fun SelectingAppBar( } }, 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 = appBarColors, + colors = primaryAppBarColors(), ) - - Column { - AnimatedVisibility( - visible = state is KeyMapAppBarState.RootGroup && 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, - ) - } - } - - AnimatedVisibility( - visible = state !is KeyMapAppBarState.Selecting && !isEditingGroupName, - ) { - val subGroups = when (state) { - is KeyMapAppBarState.ChildGroup -> state.subGroups - is KeyMapAppBarState.RootGroup -> state.subGroups - is KeyMapAppBarState.Selecting -> emptyList() - } - - Surface(color = appBarContainerColor) { - GroupRow( - modifier = Modifier - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - .fillMaxWidth(), - groups = subGroups, - onNewGroupClick = onNewGroupClick, - onGroupClick = {}, - ) - } - } - } } @Composable @@ -386,12 +423,13 @@ private fun AppBarActions( private fun GroupNameRow( modifier: Modifier = Modifier, name: String, - onRenameClick: suspend (String) -> Boolean = { true }, isEditing: Boolean, + onRenameClick: suspend (String) -> Boolean = { true }, + onEditClick: () -> Unit = {}, ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } - var newName by rememberSaveable { mutableStateOf(name) } + var textFieldValue by remember { mutableStateOf(TextFieldValue(name)) } var error: String? by rememberSaveable { mutableStateOf(null) } LaunchedEffect(isEditing) { @@ -409,54 +447,88 @@ private fun GroupNameRow( } } - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - modifier = Modifier - .focusRequester(focusRequester), - value = newName, - placeholder = { Text(name) }, - onValueChange = { - error = null - newName = it - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - showKeyboardOnFocus = true, - ), - colors = if (isEditing) { - OutlinedTextFieldDefaults.colors() - } else { - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - disabledTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + AnimatedContent(isEditing) { isEditing -> + Row(modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.Top) { + val interactionSource = remember { MutableInteractionSource() } + + // TODO handle error squishing the text field + + val isDoubleTap by interactionSource.collectIsDoubleTapAsState() + + LaunchedEffect(isDoubleTap) { + val endRange = if (isDoubleTap) textFieldValue.text.length else 0 + + textFieldValue = textFieldValue.copy( + selection = TextRange(start = 0, end = endRange), ) - }, - keyboardActions = KeyboardActions(onDone = { onDoneClick(newName) }), - isError = error != null, - supportingText = if (error == null) { - null - } else { - { Text(error!!) } - }, - enabled = isEditing, - ) + } + BasicTextField( + modifier = Modifier + .focusRequester(focusRequester) + .widthIn(max = 300.dp) + .fillMaxHeight() + .heightIn(max = 30.dp) + .then( + if (isEditing) { + Modifier.weight(1f) + } else { + Modifier + }, + ), + value = textFieldValue, + onValueChange = { + error = null + textFieldValue = it + }, + enabled = isEditing, + keyboardActions = KeyboardActions(onDone = { onDoneClick(textFieldValue.text) }), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + showKeyboardOnFocus = true, + ), + interactionSource = interactionSource, + ) { innerTextField -> + @OptIn(ExperimentalMaterial3Api::class) + OutlinedTextFieldDefaults.DecorationBox( + value = textFieldValue.text, + placeholder = { Text(name) }, + innerTextField = 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, + ), + ) + } - AnimatedContent(isEditing) { isEditing -> if (isEditing) { - IconButton(onClick = { onDoneClick(newName) }) { + IconButton(onClick = { onDoneClick(textFieldValue.text) }) { Icon( Icons.Rounded.Done, contentDescription = stringResource(R.string.home_app_bar_save_group_name), ) } } else { - IconButton(onClick = { - focusRequester.freeFocus() - focusRequester.requestFocus() - }) { + IconButton(onClick = onEditClick) { Icon( Icons.Rounded.Edit, contentDescription = stringResource(R.string.home_app_bar_edit_group_name), @@ -467,6 +539,33 @@ private fun GroupNameRow( } } +/** + * This is required due to a bug in compose where double tapping in BasicTextFields doesn't work. + * See https://issuetracker.google.com/issues/137321832 + */ +@Composable +private fun InteractionSource.collectIsDoubleTapAsState(): MutableState { + val isDoubleTap = remember { mutableStateOf(false) } + var firstInteractionTimeInMillis = 0L + LaunchedEffect(this) { + interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> { + val pressTimeInMillis = System.currentTimeMillis() + if (pressTimeInMillis - firstInteractionTimeInMillis <= 500L) { + firstInteractionTimeInMillis = 0 + isDoubleTap.value = true + } else { + firstInteractionTimeInMillis = System.currentTimeMillis() + isDoubleTap.value = false + } + } + } + } + } + return isDoubleTap +} + @Composable private fun AppBarStatus( isPaused: Boolean, @@ -647,13 +746,13 @@ private fun groupSampleList(): List { @Composable private fun KeyMapsChildGroupPreview() { val state = KeyMapAppBarState.ChildGroup( - groupName = "My group", + groupName = "Untitled group 23", subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, ) KeyMapperTheme { - KeyMapAppBar(state = state, isEditingGroupName = false) + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) } } @@ -662,7 +761,7 @@ private fun KeyMapsChildGroupPreview() { @Composable private fun KeyMapsChildGroupEditingPreview() { val state = KeyMapAppBarState.ChildGroup( - groupName = "My group", + groupName = "Untitled group 23", subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, 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 eb7836a17b..0f0d077ecf 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 @@ -644,6 +644,10 @@ class KeyMapListViewModel( } } + fun onEditGroupNameClick() { + isEditingGroupName = true + } + fun onNewGroupClick() { coroutineScope.launch { listKeyMaps.newGroup() From 533eabc63e1f62149015ea0ccd08b62d446d599d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 16:26:29 -0600 Subject: [PATCH 42/94] #320 WIP: child group app bar --- .../sds100/keymapper/home/KeyMapAppBar.kt | 200 +++++++++--------- 1 file changed, 96 insertions(+), 104 deletions(-) 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 index cb3ffbcd31..4b2bad113e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -13,9 +13,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -25,7 +24,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight 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.layout.widthIn @@ -53,6 +51,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextFieldDefaults @@ -65,7 +64,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -83,9 +81,7 @@ 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 @@ -158,28 +154,48 @@ fun KeyMapAppBar( onSelectAllClick = onSelectAllClick, ) - is KeyMapAppBarState.ChildGroup -> ChildGroupAppBar( - modifier = modifier, - state = state, - onBackClick = onBackClick, - onEditGroupNameClick = onNewGroupClick, - onRenameGroupClick = onRenameGroupClick, - onEditClick = onEditGroupNameClick, - isEditingGroupName = isEditingGroupName, - actions = { - AnimatedVisibility(!isEditingGroupName) { - AppBarActions( - state, - onSelectAllClick, - onHelpClick, - onSettingsClick, - onAboutClick, - onExportClick, - onImportClick, - ) - } - }, - ) + 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(state.groupName) } + + ChildGroupAppBar( + modifier = modifier, + value = newName, + placeholder = state.groupName, + onValueChange = { + newName = it + error = null + }, + onRenameClick = { + scope.launch { + if (!onRenameGroupClick(newName)) { + error = uniqueErrorText + } + } + }, + onBackClick = onBackClick, + onNewGroupClick = onNewGroupClick, + onEditClick = onEditGroupNameClick, + isEditingGroupName = isEditingGroupName, + actions = { + AnimatedVisibility(!isEditingGroupName) { + AppBarActions( + state, + onSelectAllClick, + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + ) + } + }, + ) + } } } @@ -279,21 +295,27 @@ private fun RootGroupAppBar( @Composable private fun ChildGroupAppBar( modifier: Modifier = Modifier, - state: KeyMapAppBarState.ChildGroup, - onBackClick: () -> Unit, - onEditGroupNameClick: () -> Unit = {}, + value: String, + placeholder: String, + onValueChange: (String) -> Unit = {}, + error: String? = null, + onBackClick: () -> Unit = {}, onEditClick: () -> Unit = {}, - onRenameGroupClick: suspend (String) -> Boolean = { true }, + onRenameClick: () -> Unit = {}, isEditingGroupName: Boolean = false, - actions: @Composable RowScope.() -> Unit, + onNewGroupClick: () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, ) { TopAppBar( modifier = modifier, title = { GroupNameRow( modifier = Modifier, - name = state.groupName, - onRenameClick = onRenameGroupClick, + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + onRenameClick = onRenameClick, + error = error, isEditing = isEditingGroupName, onEditClick = onEditClick, ) @@ -422,52 +444,30 @@ private fun AppBarActions( @Composable private fun GroupNameRow( modifier: Modifier = Modifier, - name: String, + value: String, + onValueChange: (String) -> Unit = {}, + placeholder: String, isEditing: Boolean, - onRenameClick: suspend (String) -> Boolean = { true }, + onRenameClick: () -> Unit, onEditClick: () -> Unit = {}, + error: String? = null, ) { - val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } - var textFieldValue by remember { mutableStateOf(TextFieldValue(name)) } - var error: String? by rememberSaveable { mutableStateOf(null) } LaunchedEffect(isEditing) { focusRequester.requestFocus() } - val uniqueErrorText = stringResource(R.string.home_app_bar_group_name_unique_error) - - fun onDoneClick(name: String) { - scope.launch { - val success = onRenameClick(name) - if (!success) { - error = uniqueErrorText - } - } - } - AnimatedContent(isEditing) { isEditing -> Row(modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.Top) { val interactionSource = remember { MutableInteractionSource() } // TODO handle error squishing the text field - val isDoubleTap by interactionSource.collectIsDoubleTapAsState() - - LaunchedEffect(isDoubleTap) { - val endRange = if (isDoubleTap) textFieldValue.text.length else 0 - - textFieldValue = textFieldValue.copy( - selection = TextRange(start = 0, end = endRange), - ) - } BasicTextField( modifier = Modifier .focusRequester(focusRequester) - .widthIn(max = 300.dp) .fillMaxHeight() - .heightIn(max = 30.dp) .then( if (isEditing) { Modifier.weight(1f) @@ -475,24 +475,24 @@ private fun GroupNameRow( Modifier }, ), - value = textFieldValue, - onValueChange = { - error = null - textFieldValue = it - }, + value = value, + onValueChange = onValueChange, + textStyle = LocalTextStyle.current, enabled = isEditing, - keyboardActions = KeyboardActions(onDone = { onDoneClick(textFieldValue.text) }), + keyboardActions = KeyboardActions(onDone = { onRenameClick() }), keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, showKeyboardOnFocus = true, ), + singleLine = true, + maxLines = 1, interactionSource = interactionSource, ) { innerTextField -> @OptIn(ExperimentalMaterial3Api::class) OutlinedTextFieldDefaults.DecorationBox( - value = textFieldValue.text, - placeholder = { Text(name) }, - innerTextField = innerTextField, + value = value, + placeholder = { Text(value, style = LocalTextStyle.current) }, + innerTextField = { Box(Modifier.width(IntrinsicSize.Min)) { innerTextField() } }, singleLine = true, colors = if (isEditing) { OutlinedTextFieldDefaults.colors() @@ -509,7 +509,7 @@ private fun GroupNameRow( supportingText = if (error == null) { null } else { - { Text(error!!, maxLines = 1) } + { Text(error, maxLines = 1) } }, visualTransformation = VisualTransformation.None, interactionSource = interactionSource, @@ -521,7 +521,7 @@ private fun GroupNameRow( } if (isEditing) { - IconButton(onClick = { onDoneClick(textFieldValue.text) }) { + IconButton(onClick = onRenameClick) { Icon( Icons.Rounded.Done, contentDescription = stringResource(R.string.home_app_bar_save_group_name), @@ -539,33 +539,6 @@ private fun GroupNameRow( } } -/** - * This is required due to a bug in compose where double tapping in BasicTextFields doesn't work. - * See https://issuetracker.google.com/issues/137321832 - */ -@Composable -private fun InteractionSource.collectIsDoubleTapAsState(): MutableState { - val isDoubleTap = remember { mutableStateOf(false) } - var firstInteractionTimeInMillis = 0L - LaunchedEffect(this) { - interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> { - val pressTimeInMillis = System.currentTimeMillis() - if (pressTimeInMillis - firstInteractionTimeInMillis <= 500L) { - firstInteractionTimeInMillis = 0 - isDoubleTap.value = true - } else { - firstInteractionTimeInMillis = System.currentTimeMillis() - isDoubleTap.value = false - } - } - } - } - } - return isDoubleTap -} - @Composable private fun AppBarStatus( isPaused: Boolean, @@ -742,11 +715,11 @@ private fun groupSampleList(): List { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun KeyMapsChildGroupPreview() { val state = KeyMapAppBarState.ChildGroup( - groupName = "Untitled group 23", + groupName = "Short name", subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, @@ -757,7 +730,7 @@ private fun KeyMapsChildGroupPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@Preview(showSystemUi = true) @Composable private fun KeyMapsChildGroupEditingPreview() { val state = KeyMapAppBarState.ChildGroup( @@ -781,6 +754,25 @@ private fun KeyMapsChildGroupEditingPreview() { } } +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupErrorPreview() { + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme { + ChildGroupAppBar( + value = "Untitled group 23", + placeholder = "Untitled group 23", + error = stringResource(R.string.home_app_bar_group_name_unique_error), + isEditingGroupName = true, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable @@ -862,7 +854,7 @@ private fun HomeStateWarningsDarkPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(widthDp = 300, heightDp = 600) +@Preview(showSystemUi = true) @Composable private fun HomeStateSelectingPreview() { val state = KeyMapAppBarState.Selecting( From 2cc2fa080d832a73b640d4d7fccc03bb22ee82a8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 16:41:54 -0600 Subject: [PATCH 43/94] #320 complete naming groups --- .../sds100/keymapper/home/KeyMapAppBar.kt | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) 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 index 4b2bad113e..4a33021b7a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -21,10 +21,11 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.BasicTextField @@ -51,14 +52,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle 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.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -166,6 +165,7 @@ fun KeyMapAppBar( modifier = modifier, value = newName, placeholder = state.groupName, + error = error, onValueChange = { newName = it error = null @@ -291,7 +291,6 @@ private fun RootGroupAppBar( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChildGroupAppBar( modifier: Modifier = Modifier, @@ -306,11 +305,30 @@ private fun ChildGroupAppBar( onNewGroupClick: () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { - TopAppBar( + Surface( modifier = modifier, - title = { + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + 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), + ) + } + + Spacer(Modifier.width(8.dp)) + GroupNameRow( - modifier = Modifier, value = value, onValueChange = onValueChange, placeholder = placeholder, @@ -319,22 +337,14 @@ private fun ChildGroupAppBar( isEditing = isEditingGroupName, onEditClick = onEditClick, ) - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.home_app_bar_pop_group), - ) - } - }, - actions = { + + Spacer(Modifier.weight(1f)) + AnimatedVisibility(visible = !isEditingGroupName) { actions() } - }, - colors = primaryAppBarColors(), - ) + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -467,7 +477,7 @@ private fun GroupNameRow( BasicTextField( modifier = Modifier .focusRequester(focusRequester) - .fillMaxHeight() + .height(IntrinsicSize.Max) .then( if (isEditing) { Modifier.weight(1f) @@ -477,7 +487,7 @@ private fun GroupNameRow( ), value = value, onValueChange = onValueChange, - textStyle = LocalTextStyle.current, + textStyle = MaterialTheme.typography.titleLarge, enabled = isEditing, keyboardActions = KeyboardActions(onDone = { onRenameClick() }), keyboardOptions = KeyboardOptions( @@ -491,8 +501,20 @@ private fun GroupNameRow( @OptIn(ExperimentalMaterial3Api::class) OutlinedTextFieldDefaults.DecorationBox( value = value, - placeholder = { Text(value, style = LocalTextStyle.current) }, - innerTextField = { Box(Modifier.width(IntrinsicSize.Min)) { innerTextField() } }, + 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() From 14db65af3aa5ed9eb1f8206f1520835c39079a38 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 29 Mar 2025 23:27:37 -0600 Subject: [PATCH 44/94] #320 create key maps in groups --- .../sds100/keymapper/groups/GroupRow.kt | 43 +++++++++++++++++++ .../keymapper/home/HomeKeyMapListScreen.kt | 14 +----- .../sds100/keymapper/home/KeyMapAppBar.kt | 4 +- .../mappings/keymaps/ConfigKeyMapFragment.kt | 11 +++-- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 6 +-- .../mappings/keymaps/ConfigKeyMapViewModel.kt | 4 +- .../keymapper/mappings/keymaps/KeyMap.kt | 5 ++- .../mappings/keymaps/KeyMapListViewModel.kt | 20 +++++++-- .../keymapper/util/ui/NavDestination.kt | 7 ++- .../keymapper/util/ui/NavigationViewModel.kt | 18 +++++--- app/src/main/res/navigation/nav_app.xml | 7 ++- .../main/res/navigation/nav_config_keymap.xml | 21 ++++----- 12 files changed, 112 insertions(+), 48 deletions(-) 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 index 2ce5ec5a1e..48188b11a6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -8,6 +8,7 @@ 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.outlined.Lock import androidx.compose.material.icons.rounded.Add import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -16,11 +17,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp 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( @@ -80,3 +84,42 @@ private fun PreviewEmpty() { GroupRow(groups = emptyList()) } } + +@Preview +@Composable +private fun PreviewOneItem() { + KeyMapperTheme { + GroupRow( + groups = listOf( + SubGroupListModel( + uid = "1", + name = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewMultipleItems() { + val ctx = LocalContext.current + + KeyMapperTheme { + GroupRow( + groups = listOf( + SubGroupListModel( + uid = "1", + name = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + SubGroupListModel( + uid = "2", + name = "Key Mapper is open", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ), + ) + } +} 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 index ad9b75ea8e..5ee1909c52 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -61,12 +61,9 @@ 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.NavDestination -import io.github.sds100.keymapper.util.ui.NavigateEvent 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 -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -152,16 +149,7 @@ fun HomeKeyMapListScreen( ) { CollapsableFloatingActionButton( modifier = Modifier.padding(bottom = 80.dp), - onClick = { - scope.launch { - viewModel.navigate( - NavigateEvent( - "config_key_map", - NavDestination.ConfigKeyMap(keyMapUid = null), - ), - ) - } - }, + onClick = viewModel::onNewKeyMapClick, showText = viewModel.showFabText, text = stringResource(R.string.home_fab_new_key_map), ) 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 index 4a33021b7a..4ec83f7202 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -305,6 +305,7 @@ private fun ChildGroupAppBar( onNewGroupClick: () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { + // Make custom top app bar because the height can not be set to fix the text field error in. Surface( modifier = modifier, color = MaterialTheme.colorScheme.primaryContainer, @@ -472,8 +473,7 @@ private fun GroupNameRow( Row(modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.Top) { val interactionSource = remember { MutableInteractionSource() } - // TODO handle error squishing the text field - + // Use a custom text field so the content padding can be customised. BasicTextField( modifier = Modifier .focusRequester(focusRequester) 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..ffd1bd3a3d 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 @@ -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/KeyMap.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt index 87a0dc9704..c60c827dd2 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 @@ -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,7 +106,7 @@ fun KeyMap.requiresImeKeyEventForwardingInPhoneCall(triggerKey: TriggerKey): Boo } object KeyMapEntityMapper { - suspend fun fromEntity( + fun fromEntity( entity: KeyMapEntity, floatingButtons: List, ): KeyMap { @@ -123,6 +124,7 @@ object KeyMapEntityMapper { actionList = actionList, constraintState = ConstraintState(constraintList, constraintMode), isEnabled = entity.isEnabled, + groupUid = entity.groupUid, ) } @@ -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/KeyMapListViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt index 0f0d077ecf..1a5e2ba034 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 @@ -36,6 +36,7 @@ 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 @@ -355,7 +356,7 @@ class KeyMapListViewModel( } } else { coroutineScope.launch { - navigate("config_key_map", NavDestination.ConfigKeyMap(uid)) + navigate("config_key_map", NavDestination.ConfigKeyMap.Open(uid)) } } } @@ -398,8 +399,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, ), ) @@ -656,6 +657,19 @@ class KeyMapListViewModel( } } + 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 -> {} 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/res/navigation/nav_app.xml b/app/src/main/res/navigation/nav_app.xml index 80e9f719ac..4a89c3be6e 100644 --- a/app/src/main/res/navigation/nav_app.xml +++ b/app/src/main/res/navigation/nav_app.xml @@ -32,7 +32,12 @@ app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right"> + 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 From 70df01b53bde11802c719cf480ba8079634fe90f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 00:18:49 -0600 Subject: [PATCH 45/94] #320 collapse/expand groups with view all button --- .../sds100/keymapper/data/db/dao/GroupDao.kt | 2 +- .../sds100/keymapper/groups/GroupRow.kt | 172 +++++++++++++++--- .../mappings/keymaps/ListKeyMapsUseCase.kt | 15 +- app/src/main/res/values/strings.xml | 1 + 4 files changed, 158 insertions(+), 32 deletions(-) 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 index c61d083751..4389d12a0e 100644 --- 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 @@ -34,7 +34,7 @@ interface GroupDao { @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") fun getGroupWithSubGroups(uid: String): Flow - @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_PARENT_UID = (:uid)") + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_PARENT_UID IS (:uid)") fun getGroupsByParent(uid: String?): Flow> @Insert(onConflict = OnConflictStrategy.ABORT) 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 index 48188b11a6..fd522beea3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -1,11 +1,17 @@ package io.github.sds100.keymapper.groups import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith 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.material.icons.Icons import androidx.compose.material.icons.outlined.Lock @@ -15,12 +21,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.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 @@ -33,23 +45,76 @@ fun GroupRow( onNewGroupClick: () -> Unit = {}, onGroupClick: (String) -> Unit = {}, ) { - FlowRow(modifier) { - AnimatedContent(groups.isEmpty()) { isEmpty -> + var viewAllState by rememberSaveable { mutableStateOf(false) } + val transition = + slideInVertically { height -> -height } togetherWith slideOutVertically { height -> height } +// +// AnimatedContent( +// viewAllState, +// // transitionSpec = { transition }, +// ) { viewAll -> + FlowRow( + modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + maxLines = if (viewAllState) { + Int.MAX_VALUE + } else { + 2 + }, + ) { + NewGroupButton( + onClick = onNewGroupClick, + text = stringResource(R.string.home_new_group_button), + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + ) + + ViewAllButton( + onClick = { viewAllState = !viewAllState }, + text = if (viewAllState) { + stringResource(R.string.home_new_hide_groups_button) + } else { + stringResource(R.string.home_new_view_all_groups_button) + }, + ) + + for (group in groups) { GroupButton( - onClick = onNewGroupClick, - text = stringResource(R.string.home_new_group_button), + onClick = { onGroupClick(group.uid) }, + text = group.name, icon = { - Icon(imageVector = Icons.Rounded.Add, null) + when (group.icon) { + is ComposeIconInfo.Drawable -> { + Icon( + painter = rememberDrawablePainter(group.icon.drawable), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified, + ) + } + + is ComposeIconInfo.Vector -> { + Icon( + imageVector = group.icon.imageVector, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + + null -> {} + } }, - showText = isEmpty, ) } - // TODO only show max 2 rows, otherwise show View All. +// } } } @Composable -private fun GroupButton( +private fun NewGroupButton( modifier: Modifier = Modifier, onClick: () -> Unit, text: String, @@ -57,13 +122,67 @@ private fun GroupButton( showText: Boolean = true, ) { Surface( - modifier = modifier, + modifier = modifier.height(36.dp), onClick = onClick, shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), ) { Row( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), + 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, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Composable +private fun ViewAllButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String, +) { + Surface( + modifier = modifier.height(36.dp), + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), + ) { + AnimatedContent(text) { text -> + Text( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun GroupButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String, + icon: @Composable () -> Unit, +) { + Surface( + modifier = modifier.height(36.dp), + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { icon() @@ -107,19 +226,26 @@ private fun PreviewMultipleItems() { val ctx = LocalContext.current KeyMapperTheme { - GroupRow( - groups = listOf( - SubGroupListModel( - uid = "1", - name = "Device is locked", - icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), - ), - SubGroupListModel( - uid = "2", - name = "Key Mapper is open", - icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + Surface { + GroupRow( + groups = listOf( + SubGroupListModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + SubGroupListModel( + uid = "2", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + SubGroupListModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), ), - ), - ) + ) + } } } 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 d4e3a20272..2a04429c34 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 @@ -146,14 +146,13 @@ class ListKeyMapsUseCaseImpl( @OptIn(ExperimentalCoroutinesApi::class) override val keyMapGroup: Flow = channelFlow { - group - .map { group -> - KeyMapGroup( - group = group.group, - subGroups = group.subGroups, - keyMaps = State.Loading, - ) - } + group.map { group -> + KeyMapGroup( + group = group.group, + subGroups = group.subGroups, + keyMaps = State.Loading, + ) + } .onEach { send(it) } .flatMapLatest { keyMapGroup -> getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b5454fd1e..328255d026 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1373,6 +1373,7 @@ New group New subgroup View all + Hide Group constraints New group constraint From 09b9b4fde28701ddc04554cedee3cbe89f440240 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 09:31:25 -0600 Subject: [PATCH 46/94] #320 opening groups works --- .../java/io/github/sds100/keymapper/backup/BackupContent.kt | 1 + .../io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt | 1 + .../java/io/github/sds100/keymapper/home/KeyMapAppBar.kt | 5 ++++- .../keymapper/mappings/keymaps/KeyMapListViewModel.kt | 6 ++++++ 4 files changed, 12 insertions(+), 1 deletion(-) 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..861109cd41 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 @@ -5,6 +5,7 @@ import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity 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, 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 index 5ee1909c52..c414cc5050 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -191,6 +191,7 @@ fun HomeKeyMapListScreen( onRenameGroupClick = viewModel::onRenameGroupClick, isEditingGroupName = viewModel.isEditingGroupName, onEditGroupNameClick = viewModel::onEditGroupNameClick, + onGroupClick = viewModel::onGroupClick, ) }, selectionBottomSheet = { 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 index 4ec83f7202..59a9d7bfa3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -116,6 +116,7 @@ fun KeyMapAppBar( onSelectAllClick: () -> Unit = {}, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), onNewGroupClick: () -> Unit = {}, + onGroupClick: (String) -> Unit = {}, onRenameGroupClick: suspend (String) -> Boolean = { true }, isEditingGroupName: Boolean = false, onEditGroupNameClick: () -> Unit = {}, @@ -131,6 +132,7 @@ fun KeyMapAppBar( onSortClick = onSortClick, onFixWarningClick = onFixWarningClick, onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, actions = { AnimatedVisibility(!isEditingGroupName) { AppBarActions( @@ -221,6 +223,7 @@ private fun RootGroupAppBar( onSortClick: () -> Unit, onFixWarningClick: (String) -> Unit, onNewGroupClick: () -> Unit, + onGroupClick: (String) -> Unit, actions: @Composable RowScope.() -> Unit, ) { // This is taken from the AppBar color code. @@ -285,7 +288,7 @@ private fun RootGroupAppBar( .fillMaxWidth(), groups = state.subGroups, onNewGroupClick = onNewGroupClick, - onGroupClick = {}, + onGroupClick = onGroupClick, ) } } 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 1a5e2ba034..ccab6a496e 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 @@ -649,6 +649,12 @@ class KeyMapListViewModel( isEditingGroupName = true } + fun onGroupClick(uid: String) { + coroutineScope.launch { + listKeyMaps.openGroup(uid) + } + } + fun onNewGroupClick() { coroutineScope.launch { listKeyMaps.newGroup() From e33d151eb0b705789d4a117fc75fdd87b6cb89e6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 09:49:59 -0600 Subject: [PATCH 47/94] #320 open nested children groups --- .../sds100/keymapper/groups/GroupRow.kt | 33 +-- .../sds100/keymapper/home/KeyMapAppBar.kt | 250 +++++++++++------- 2 files changed, 174 insertions(+), 109 deletions(-) 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 index fd522beea3..4401c67ab7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -1,9 +1,6 @@ package io.github.sds100.keymapper.groups import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow @@ -46,13 +43,6 @@ fun GroupRow( onGroupClick: (String) -> Unit = {}, ) { var viewAllState by rememberSaveable { mutableStateOf(false) } - val transition = - slideInVertically { height -> -height } togetherWith slideOutVertically { height -> height } -// -// AnimatedContent( -// viewAllState, -// // transitionSpec = { transition }, -// ) { viewAll -> FlowRow( modifier, verticalArrangement = Arrangement.spacedBy(8.dp), @@ -72,14 +62,16 @@ fun GroupRow( showText = groups.isEmpty(), ) - ViewAllButton( - onClick = { viewAllState = !viewAllState }, - text = if (viewAllState) { - stringResource(R.string.home_new_hide_groups_button) - } else { - stringResource(R.string.home_new_view_all_groups_button) - }, - ) + if (groups.isNotEmpty()) { + ViewAllButton( + onClick = { viewAllState = !viewAllState }, + text = if (viewAllState) { + stringResource(R.string.home_new_hide_groups_button) + } else { + stringResource(R.string.home_new_view_all_groups_button) + }, + ) + } for (group in groups) { GroupButton( @@ -109,7 +101,6 @@ fun GroupRow( }, ) } -// } } } @@ -126,6 +117,7 @@ private fun NewGroupButton( onClick = onClick, shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), + color = Color.Transparent, ) { Row( modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), @@ -156,6 +148,7 @@ private fun ViewAllButton( onClick = onClick, shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), + color = Color.Transparent, ) { AnimatedContent(text) { text -> Text( @@ -179,7 +172,7 @@ private fun GroupButton( modifier = modifier.height(36.dp), onClick = onClick, shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainerHighest, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), ) { Row( modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), 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 index 59a9d7bfa3..5aa4d4cdf0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -35,6 +35,7 @@ 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.Done import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ErrorOutline @@ -52,6 +53,7 @@ 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 @@ -123,66 +125,17 @@ fun KeyMapAppBar( ) { BackHandler(onBack = onBackClick) - 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( - state, - onSelectAllClick, - 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(state.groupName) } - - ChildGroupAppBar( + AnimatedContent(state) { state -> + when (state) { + is KeyMapAppBarState.RootGroup -> RootGroupAppBar( modifier = modifier, - value = newName, - placeholder = state.groupName, - error = error, - onValueChange = { - newName = it - error = null - }, - onRenameClick = { - scope.launch { - if (!onRenameGroupClick(newName)) { - error = uniqueErrorText - } - } - }, - onBackClick = onBackClick, + state = state, + scrollBehavior = scrollBehavior, + onTogglePausedClick = onTogglePausedClick, + onSortClick = onSortClick, + onFixWarningClick = onFixWarningClick, onNewGroupClick = onNewGroupClick, - onEditClick = onEditGroupNameClick, - isEditingGroupName = isEditingGroupName, + onGroupClick = onGroupClick, actions = { AnimatedVisibility(!isEditingGroupName) { AppBarActions( @@ -197,6 +150,59 @@ fun KeyMapAppBar( } }, ) + + 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(state.groupName) } + + ChildGroupAppBar( + modifier = modifier, + value = newName, + placeholder = state.groupName, + error = error, + onValueChange = { + newName = it + error = null + }, + onRenameClick = { + scope.launch { + if (!onRenameGroupClick(newName)) { + error = uniqueErrorText + } + } + }, + onBackClick = onBackClick, + onNewGroupClick = onNewGroupClick, + onEditClick = onEditGroupNameClick, + isEditingGroupName = isEditingGroupName, + subGroups = state.subGroups, + onGroupClick = onGroupClick, + actions = { + AnimatedVisibility(!isEditingGroupName) { + AppBarActions( + state, + onSelectAllClick, + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + ) + } + }, + ) + } } } } @@ -305,7 +311,9 @@ private fun ChildGroupAppBar( onEditClick: () -> Unit = {}, onRenameClick: () -> Unit = {}, isEditingGroupName: Boolean = false, + subGroups: List, onNewGroupClick: () -> Unit = {}, + onGroupClick: (String) -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { // Make custom top app bar because the height can not be set to fix the text field error in. @@ -314,38 +322,51 @@ private fun ChildGroupAppBar( color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) { - 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), - ) - } + 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), + ) + } - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) - GroupNameRow( - value = value, - onValueChange = onValueChange, - placeholder = placeholder, - onRenameClick = onRenameClick, - error = error, - isEditing = isEditingGroupName, - onEditClick = onEditClick, - ) + GroupNameRow( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + onRenameClick = onRenameClick, + error = error, + isEditing = isEditingGroupName, + onEditClick = onEditClick, + ) + + Spacer(Modifier.weight(1f)) - Spacer(Modifier.weight(1f)) + AnimatedVisibility(visible = !isEditingGroupName) { + actions() + } + } - AnimatedVisibility(visible = !isEditingGroupName) { - actions() + AnimatedVisibility(!isEditingGroupName) { + GroupRow( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + ) } } } @@ -490,7 +511,7 @@ private fun GroupNameRow( ), value = value, onValueChange = onValueChange, - textStyle = MaterialTheme.typography.titleLarge, + textStyle = MaterialTheme.typography.titleLarge.copy(color = LocalContentColor.current), enabled = isEditing, keyboardActions = KeyboardActions(onDone = { onRenameClick() }), keyboardOptions = KeyboardOptions( @@ -732,10 +753,20 @@ private fun groupSampleList(): List { return listOf( SubGroupListModel( - uid = "0", + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + SubGroupListModel( + uid = "2", name = "Key Mapper", icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), ), + SubGroupListModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), ) } @@ -754,6 +785,21 @@ private fun KeyMapsChildGroupPreview() { } } +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun KeyMapsChildGroupDarkPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Short name", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + constraintMode = ConstraintMode.AND, + ) + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Preview(showSystemUi = true) @Composable @@ -779,6 +825,31 @@ private fun KeyMapsChildGroupEditingPreview() { } } +@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, + ) + + val focusRequester = FocusRequester() + + LaunchedEffect("") { + focusRequester.requestFocus() + } + + KeyMapperTheme(darkTheme = true) { + KeyMapAppBar( + state = state, + isEditingGroupName = true, + ) + } +} + @Preview(showSystemUi = true) @Composable private fun KeyMapsChildGroupErrorPreview() { @@ -794,6 +865,7 @@ private fun KeyMapsChildGroupErrorPreview() { placeholder = "Untitled group 23", error = stringResource(R.string.home_app_bar_group_name_unique_error), isEditingGroupName = true, + subGroups = emptyList(), ) } } @@ -873,7 +945,7 @@ private fun HomeStateWarningsDarkPreview() { warnings = warnings, isPaused = true, ) - KeyMapperTheme { + KeyMapperTheme(darkTheme = true) { KeyMapAppBar(state = state) } } From f39df4f7f36f80249f8f54de0cb472f66efa4dbd Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 10:58:35 -0600 Subject: [PATCH 48/94] #320 add group breadcrumbs --- .../sds100/keymapper/data/db/dao/GroupDao.kt | 5 +- .../data/repositories/GroupRepository.kt | 5 + .../sds100/keymapper/groups/GroupRow.kt | 5 +- .../keymapper/home/HomeKeyMapListScreen.kt | 5 +- .../sds100/keymapper/home/KeyMapAppBar.kt | 124 ++++++++++++++++-- .../keymaps/CreateKeyMapShortcutViewModel.kt | 9 ++ .../mappings/keymaps/KeyMapAppBarState.kt | 1 + .../keymapper/mappings/keymaps/KeyMapGroup.kt | 1 + .../mappings/keymaps/KeyMapListScreen.kt | 2 +- .../mappings/keymaps/KeyMapListViewModel.kt | 16 ++- .../mappings/keymaps/ListKeyMapsUseCase.kt | 59 +++++++-- app/src/main/res/values/strings.xml | 1 + 12 files changed, 199 insertions(+), 34 deletions(-) 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 index 4389d12a0e..a4bd84343c 100644 --- 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 @@ -28,6 +28,9 @@ interface GroupDao { @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 @@ -46,6 +49,6 @@ interface GroupDao { @Delete suspend fun delete(vararg group: GroupEntity) - @Query("DELETE FROM $TABLE_NAME WHERE $KEY_UID in (:uid)") + @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/repositories/GroupRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt index d5f08c016f..9196376161 100644 --- 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext interface GroupRepository { fun getKeyMapsByGroup(groupUid: String): Flow suspend fun getGroup(uid: String): GroupEntity? + suspend fun getGroups(vararg uid: String): Flow> fun getGroupsByParent(uid: String?): Flow> fun getGroupWithSubGroups(uid: String): Flow suspend fun insert(groupEntity: GroupEntity) @@ -35,6 +36,10 @@ class RoomGroupRepository( return withContext(dispatchers.io()) { dao.getById(uid) } } + override suspend fun getGroups(vararg uid: String): Flow> { + return withContext(dispatchers.io()) { dao.getManyByIdFlow(*uid) } + } + override fun getGroupsByParent(uid: String?): Flow> { return dao.getGroupsByParent(uid).flowOn(dispatchers.io()) } 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 index 4401c67ab7..4a10b82b4d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -10,6 +10,8 @@ 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 @@ -44,7 +46,7 @@ fun GroupRow( ) { var viewAllState by rememberSaveable { mutableStateOf(false) } FlowRow( - modifier, + modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), maxLines = if (viewAllState) { @@ -182,6 +184,7 @@ private fun GroupButton( Spacer(modifier = Modifier.width(8.dp)) Text( text = text, + maxLines = 1, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) 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 index c414cc5050..6db95447a7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -136,10 +135,9 @@ fun HomeKeyMapListScreen( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val uriHandler = LocalUriHandler.current val helpUrl = stringResource(R.string.url_quick_start_guide) - val scope = rememberCoroutineScope() HomeKeyMapListScreen( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), snackbarState = snackbarState, floatingActionButton = { AnimatedVisibility( @@ -157,7 +155,6 @@ fun HomeKeyMapListScreen( }, listContent = { KeyMapList( - modifier = modifier, lazyListState = rememberLazyListState(), listItems = state.listItems, footerText = stringResource(R.string.home_key_map_list_footer_text), 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 index 5aa4d4cdf0..a910230619 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -13,6 +13,7 @@ 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 @@ -28,12 +29,14 @@ 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.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.rounded.Done @@ -51,9 +54,11 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextFieldDefaults @@ -64,6 +69,7 @@ import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -118,7 +124,7 @@ fun KeyMapAppBar( onSelectAllClick: () -> Unit = {}, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), onNewGroupClick: () -> Unit = {}, - onGroupClick: (String) -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, onRenameGroupClick: suspend (String) -> Boolean = { true }, isEditingGroupName: Boolean = false, onEditGroupNameClick: () -> Unit = {}, @@ -168,7 +174,7 @@ fun KeyMapAppBar( ChildGroupAppBar( modifier = modifier, - value = newName, + groupName = newName, placeholder = state.groupName, error = error, onValueChange = { @@ -187,6 +193,7 @@ fun KeyMapAppBar( onEditClick = onEditGroupNameClick, isEditingGroupName = isEditingGroupName, subGroups = state.subGroups, + parentGroups = state.parentGroups, onGroupClick = onGroupClick, actions = { AnimatedVisibility(!isEditingGroupName) { @@ -303,7 +310,7 @@ private fun RootGroupAppBar( @Composable private fun ChildGroupAppBar( modifier: Modifier = Modifier, - value: String, + groupName: String, placeholder: String, onValueChange: (String) -> Unit = {}, error: String? = null, @@ -312,8 +319,9 @@ private fun ChildGroupAppBar( onRenameClick: () -> Unit = {}, isEditingGroupName: Boolean = false, subGroups: List, + parentGroups: List, onNewGroupClick: () -> Unit = {}, - onGroupClick: (String) -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { // Make custom top app bar because the height can not be set to fix the text field error in. @@ -341,8 +349,10 @@ private fun ChildGroupAppBar( Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) + GroupNameRow( - value = value, + value = groupName, onValueChange = onValueChange, placeholder = placeholder, onRenameClick = onRenameClick, @@ -359,19 +369,100 @@ private fun ChildGroupAppBar( } AnimatedVisibility(!isEditingGroupName) { - GroupRow( - modifier = Modifier - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) - .fillMaxWidth(), - groups = subGroups, - onNewGroupClick = onNewGroupClick, - onGroupClick = onGroupClick, - ) + Column { + GroupRow( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + ) + + HorizontalDivider() + + val scrollState = rememberScrollState() + + LaunchedEffect(parentGroups) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + BreadcrumbRow( + modifier = Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .padding( + start = 8.dp, + end = 8.dp, + bottom = 8.dp, + top = 8.dp, + ), + groups = parentGroups, + onGroupClick = onGroupClick, + ) + } } } } } +@Composable +private fun BreadcrumbRow( + modifier: Modifier = Modifier, + groups: List, + onGroupClick: (String?) -> Unit, +) { + Row(modifier = modifier) { + val color = LocalContentColor.current.copy(alpha = 0.6f) + Breadcrumb( + text = stringResource(R.string.home_groups_breadcrumb_home), + onClick = { onGroupClick(null) }, + color = color, + ) + + 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 + }, + ) + } + } +} + +@Composable +private fun Breadcrumb( + modifier: Modifier = Modifier, + text: String, + color: Color, + onClick: () -> Unit, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = Color.Transparent, + ) { + Text( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + text = text, + style = MaterialTheme.typography.labelMedium, + color = color, + maxLines = 1, + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SelectingAppBar( @@ -779,6 +870,7 @@ private fun KeyMapsChildGroupPreview() { subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, + parentGroups = groupSampleList(), ) KeyMapperTheme { KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) @@ -794,6 +886,7 @@ private fun KeyMapsChildGroupDarkPreview() { subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, + parentGroups = emptyList(), ) KeyMapperTheme(darkTheme = true) { KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) @@ -809,6 +902,7 @@ private fun KeyMapsChildGroupEditingPreview() { subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, + parentGroups = emptyList(), ) val focusRequester = FocusRequester() @@ -834,6 +928,7 @@ private fun KeyMapsChildGroupEditingDarkPreview() { subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, + parentGroups = emptyList(), ) val focusRequester = FocusRequester() @@ -861,11 +956,12 @@ private fun KeyMapsChildGroupErrorPreview() { KeyMapperTheme { ChildGroupAppBar( - value = "Untitled group 23", + groupName = "Untitled group 23", placeholder = "Untitled group 23", error = stringResource(R.string.home_app_bar_group_name_unique_error), isEditingGroupName = true, subGroups = emptyList(), + parentGroups = emptyList(), ) } } 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 6120e084b2..fea1235545 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 @@ -109,6 +109,14 @@ class CreateKeyMapShortcutViewModel( ) } + val parentGroupListItems = keyMapGroup.parents.map { group -> + SubGroupListModel( + uid = group.uid, + name = group.name, + icon = null, + ) + } + val appBarState = if (keyMapGroup.group == null) { KeyMapAppBarState.RootGroup( subGroups = subGroupListItems, @@ -121,6 +129,7 @@ class CreateKeyMapShortcutViewModel( subGroups = subGroupListItems, constraints = emptyList(), constraintMode = ConstraintMode.AND, + parentGroups = parentGroupListItems, ) } 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 index 60e343ef0c..88e8fe2c5c 100644 --- 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 @@ -18,6 +18,7 @@ sealed class KeyMapAppBarState { val constraints: List, val constraintMode: ConstraintMode, val subGroups: List, + val parentGroups: 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 index 6fef4dfc03..de2910ec78 100644 --- 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 @@ -6,5 +6,6 @@ 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/KeyMapListScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListScreen.kt index 399429d31c..8bace163d2 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 @@ -187,7 +187,7 @@ private fun LoadedKeyMapList( // Give some space at the end of the list so that the FAB doesn't block the items. item { - Spacer(Modifier.height(140.dp)) + Spacer(Modifier.height(100.dp)) } } } 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 ccab6a496e..fc3cf8cc94 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 @@ -125,6 +125,7 @@ class KeyMapListViewModel( group = null, subGroups = emptyList(), keyMaps = State.Loading, + parents = emptyList(), ), ) @@ -307,6 +308,14 @@ class KeyMapListViewModel( ) } + val parentGroupListItems = keyMapGroup.parents.map { group -> + SubGroupListModel( + uid = group.uid, + name = group.name, + icon = null, + ) + } + if (keyMapGroup.group == null) { return KeyMapAppBarState.RootGroup( subGroups = subGroupListItems, @@ -322,6 +331,7 @@ class KeyMapListViewModel( ), constraintMode = keyMapGroup.group.constraintState.mode, subGroups = subGroupListItems, + parentGroups = parentGroupListItems, ) } } @@ -620,7 +630,9 @@ class KeyMapListViewModel( state.value.appBarState is KeyMapAppBarState.ChildGroup -> { if (isEditingGroupName && isNewGroup) { - listKeyMaps.deleteGroup() + coroutineScope.launch { + listKeyMaps.deleteGroup() + } } else { coroutineScope.launch { listKeyMaps.popGroup() @@ -649,7 +661,7 @@ class KeyMapListViewModel( isEditingGroupName = true } - fun onGroupClick(uid: String) { + fun onGroupClick(uid: String?) { coroutineScope.launch { listKeyMaps.openGroup(uid) } 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 2a04429c34..fdc55daf88 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 @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.backup.BackupUtils 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.groups.Group import io.github.sds100.keymapper.groups.GroupEntityMapper import io.github.sds100.keymapper.groups.GroupWithSubGroups import io.github.sds100.keymapper.system.files.FileAdapter @@ -44,6 +45,7 @@ class ListKeyMapsUseCaseImpl( DisplayKeyMapUseCase by displayKeyMapUseCase { private val groupUid = MutableStateFlow(null) + private val parentGroupUids = MutableStateFlow>(emptyList()) override suspend fun newGroup() { val defaultName = resourceProvider.getString(R.string.default_group_name) @@ -54,11 +56,17 @@ class ListKeyMapsUseCaseImpl( } groupUid.update { group.uid } + parentGroupUids.update { it.plus(group.uid) } } - override fun deleteGroup() { + override suspend fun deleteGroup() { groupUid.value?.also { groupUid -> - this.groupUid.value = null + val group = groupRepository.getGroup(groupUid) ?: return + + this.groupUid.value = group.parentUid + this.parentGroupUids.update { list -> + list.takeWhile { it != group.parentUid } + } groupRepository.delete(groupUid) } } @@ -83,10 +91,24 @@ class ListKeyMapsUseCaseImpl( return true } - override suspend fun openGroup(uid: String) { - // Check if the group exists. - val group = groupRepository.getGroup(uid) ?: return - groupUid.update { group.uid } + 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() { @@ -96,10 +118,12 @@ class ListKeyMapsUseCaseImpl( // 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) } } } @@ -144,16 +168,29 @@ class ListKeyMapsUseCaseImpl( } } + @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 { - group.map { group -> + combine(group, parentGroups) { group, parentGroups -> KeyMapGroup( group = group.group, subGroups = group.subGroups, keyMaps = State.Loading, + parents = parentGroups, ) - } - .onEach { send(it) } + }.onEach { send(it) } .flatMapLatest { keyMapGroup -> getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } }.collect { @@ -221,9 +258,9 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { val keyMapGroup: Flow suspend fun newGroup() - suspend fun openGroup(uid: String) + suspend fun openGroup(uid: String?) suspend fun popGroup() - fun deleteGroup() + suspend fun deleteGroup() suspend fun renameGroup(name: String): Boolean fun deleteKeyMap(vararg uid: String) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 328255d026..55a4956b2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1445,4 +1445,5 @@ Edit group name Save group name Name must be unique! + Home From 57474f784495c1200ff0da67a2ca79eb75eed99d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 11:15:00 -0600 Subject: [PATCH 49/94] #320 deleting groups --- .../keymapper/groups/DeleteGroupDialog.kt | 42 ++++++ .../keymapper/home/HomeKeyMapListScreen.kt | 1 + .../sds100/keymapper/home/KeyMapAppBar.kt | 131 ++++++++++-------- .../mappings/keymaps/KeyMapListViewModel.kt | 6 + .../mappings/keymaps/ListKeyMapsUseCase.kt | 2 +- app/src/main/res/values/strings.xml | 6 + 6 files changed, 127 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt 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/home/HomeKeyMapListScreen.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt index 6db95447a7..eb17e9b0b8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -189,6 +189,7 @@ fun HomeKeyMapListScreen( isEditingGroupName = viewModel.isEditingGroupName, onEditGroupNameClick = viewModel::onEditGroupNameClick, onGroupClick = viewModel::onGroupClick, + onDeleteGroupClick = viewModel::onDeleteGroupClick, ) }, selectionBottomSheet = { 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 index a910230619..38559ef8d4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.automirrored.rounded.HelpOutline import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight 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 @@ -96,6 +97,7 @@ 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.GroupRow import io.github.sds100.keymapper.groups.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState @@ -128,6 +130,7 @@ fun KeyMapAppBar( onRenameGroupClick: suspend (String) -> Boolean = { true }, isEditingGroupName: Boolean = false, onEditGroupNameClick: () -> Unit = {}, + onDeleteGroupClick: () -> Unit = {}, ) { BackHandler(onBack = onBackClick) @@ -145,8 +148,6 @@ fun KeyMapAppBar( actions = { AnimatedVisibility(!isEditingGroupName) { AppBarActions( - state, - onSelectAllClick, onHelpClick, onSettingsClick, onAboutClick, @@ -172,6 +173,16 @@ fun KeyMapAppBar( var newName by remember { mutableStateOf(state.groupName) } + var showDeleteGroupDialog by remember { mutableStateOf(false) } + + if (showDeleteGroupDialog) { + DeleteGroupDialog( + groupName = state.groupName, + onDismissRequest = { showDeleteGroupDialog = false }, + onDeleteClick = onDeleteGroupClick, + ) + } + ChildGroupAppBar( modifier = modifier, groupName = newName, @@ -198,13 +209,15 @@ fun KeyMapAppBar( actions = { AnimatedVisibility(!isEditingGroupName) { AppBarActions( - state, - onSelectAllClick, onHelpClick, onSettingsClick, onAboutClick, onExportClick, onImportClick, + showDeleteGroup = true, + onDeleteGroupClick = { + showDeleteGroupDialog = true + }, ) } }, @@ -352,6 +365,7 @@ private fun ChildGroupAppBar( Spacer(Modifier.width(8.dp)) GroupNameRow( + modifier = Modifier.weight(1f), value = groupName, onValueChange = onValueChange, placeholder = placeholder, @@ -361,8 +375,6 @@ private fun ChildGroupAppBar( onEditClick = onEditClick, ) - Spacer(Modifier.weight(1f)) - AnimatedVisibility(visible = !isEditingGroupName) { actions() } @@ -503,67 +515,56 @@ private fun SelectingAppBar( @Composable private fun AppBarActions( - state: KeyMapAppBarState, - onSelectAllClick: () -> Unit, onHelpClick: () -> Unit, onSettingsClick: () -> Unit, onAboutClick: () -> Unit, onExportClick: () -> Unit, onImportClick: () -> Unit, + showDeleteGroup: Boolean = false, + onDeleteGroupClick: () -> Unit = {}, ) { var expandedDropdown by rememberSaveable { mutableStateOf(false) } - AnimatedContent(state is KeyMapAppBarState.Selecting) { isSelecting -> - if (isSelecting && state is KeyMapAppBarState.Selecting) { - 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) - } - } 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), - ) - } + Row { + IconButton(onClick = onHelpClick) { + Icon( + Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = stringResource(R.string.home_app_bar_help), + ) + } - AppBarDropdownMenu( - expanded = expandedDropdown, - onSettingsClick = { - expandedDropdown = false - onSettingsClick() - }, - onAboutClick = { - expandedDropdown = false - onAboutClick() - }, - onExportClick = { - expandedDropdown = false - onExportClick() - }, - onImportClick = { - expandedDropdown = false - onImportClick() - }, - onDismissRequest = { expandedDropdown = false }, - ) - } + 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() + }, + ) } } @@ -584,8 +585,8 @@ private fun GroupNameRow( focusRequester.requestFocus() } - AnimatedContent(isEditing) { isEditing -> - Row(modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.Top) { + 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. @@ -792,11 +793,21 @@ private fun AppBarDropdownMenu( 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)) }, @@ -866,7 +877,7 @@ private fun groupSampleList(): List { @Composable private fun KeyMapsChildGroupPreview() { val state = KeyMapAppBarState.ChildGroup( - groupName = "Short name", + groupName = "Very very very very very long name", subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, 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 fc3cf8cc94..fe6b2af30a 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 @@ -667,6 +667,12 @@ class KeyMapListViewModel( } } + fun onDeleteGroupClick() { + coroutineScope.launch { + listKeyMaps.deleteGroup() + } + } + fun onNewGroupClick() { coroutineScope.launch { listKeyMaps.newGroup() 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 fdc55daf88..b9f299e813 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 @@ -65,7 +65,7 @@ class ListKeyMapsUseCaseImpl( this.groupUid.value = group.parentUid this.parentGroupUids.update { list -> - list.takeWhile { it != group.parentUid } + list.takeWhile { it != group.uid } } groupRepository.delete(groupUid) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55a4956b2b..36463d8ae4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1333,6 +1333,7 @@ Running Settings + Delete group About Export all Import @@ -1446,4 +1447,9 @@ 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 From f30e1f3ca431000452d606938dfa09cb0218f54c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 11:22:02 -0600 Subject: [PATCH 50/94] #320 select all text when renaming --- .../sds100/keymapper/home/KeyMapAppBar.kt | 24 ++++++++++++------- .../mappings/keymaps/KeyMapListViewModel.kt | 10 +++++--- 2 files changed, 23 insertions(+), 11 deletions(-) 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 index 38559ef8d4..d62d353116 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -89,7 +89,9 @@ 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 @@ -171,10 +173,16 @@ fun KeyMapAppBar( var error: String? by rememberSaveable { mutableStateOf(null) } - var newName by remember { mutableStateOf(state.groupName) } + var newName by remember { mutableStateOf(TextFieldValue(state.groupName)) } var showDeleteGroupDialog by remember { mutableStateOf(false) } + LaunchedEffect(isEditingGroupName) { + if (isEditingGroupName) { + newName = newName.copy(selection = TextRange(0, newName.text.length)) + } + } + if (showDeleteGroupDialog) { DeleteGroupDialog( groupName = state.groupName, @@ -194,7 +202,7 @@ fun KeyMapAppBar( }, onRenameClick = { scope.launch { - if (!onRenameGroupClick(newName)) { + if (!onRenameGroupClick(newName.text)) { error = uniqueErrorText } } @@ -323,9 +331,9 @@ private fun RootGroupAppBar( @Composable private fun ChildGroupAppBar( modifier: Modifier = Modifier, - groupName: String, + groupName: TextFieldValue, placeholder: String, - onValueChange: (String) -> Unit = {}, + onValueChange: (TextFieldValue) -> Unit = {}, error: String? = null, onBackClick: () -> Unit = {}, onEditClick: () -> Unit = {}, @@ -571,8 +579,8 @@ private fun AppBarActions( @Composable private fun GroupNameRow( modifier: Modifier = Modifier, - value: String, - onValueChange: (String) -> Unit = {}, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit = {}, placeholder: String, isEditing: Boolean, onRenameClick: () -> Unit, @@ -616,7 +624,7 @@ private fun GroupNameRow( ) { innerTextField -> @OptIn(ExperimentalMaterial3Api::class) OutlinedTextFieldDefaults.DecorationBox( - value = value, + value = value.text, placeholder = { Text( placeholder, @@ -967,7 +975,7 @@ private fun KeyMapsChildGroupErrorPreview() { KeyMapperTheme { ChildGroupAppBar( - groupName = "Untitled group 23", + groupName = TextFieldValue("Untitled group 23"), placeholder = "Untitled group 23", error = stringResource(R.string.home_app_bar_group_name_unique_error), isEditingGroupName = true, 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 fe6b2af30a..f3290c07ca 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 @@ -629,9 +629,13 @@ class KeyMapListViewModel( } state.value.appBarState is KeyMapAppBarState.ChildGroup -> { - if (isEditingGroupName && isNewGroup) { - coroutineScope.launch { - listKeyMaps.deleteGroup() + if (isEditingGroupName) { + if (isNewGroup) { + coroutineScope.launch { + listKeyMaps.deleteGroup() + } + } else { + isEditingGroupName = false } } else { coroutineScope.launch { From d93aeb0289fa897f686aa999340a57438b0d4572 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 14:42:24 -0600 Subject: [PATCH 51/94] #320 adding/removing group constraints works --- .../keymapper/groups/GroupBreadcrumbRow.kt | 76 ++++++ .../keymapper/groups/GroupConstraintRow.kt | 252 ++++++++++++++++++ .../sds100/keymapper/groups/GroupRow.kt | 10 +- .../keymapper/home/HomeKeyMapListScreen.kt | 3 + .../sds100/keymapper/home/KeyMapAppBar.kt | 130 ++++----- .../mappings/keymaps/KeyMapListViewModel.kt | 24 ++ .../mappings/keymaps/ListKeyMapsUseCase.kt | 88 ++++-- app/src/main/res/values/strings.xml | 3 +- 8 files changed, 489 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt 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..0a7e237e67 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt @@ -0,0 +1,76 @@ +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, +) { + Row(modifier = modifier) { + val color = LocalContentColor.current.copy(alpha = 0.6f) + Breadcrumb( + text = stringResource(R.string.home_groups_breadcrumb_home), + onClick = { onGroupClick(null) }, + color = color, + ) + + 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 + }, + ) + } + } +} + +@Composable +private fun Breadcrumb( + modifier: Modifier = Modifier, + text: String, + color: Color, + onClick: () -> Unit, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.small, + color = Color.Transparent, + ) { + 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..9f24c9fbaa --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -0,0 +1,252 @@ +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 = {}, +) { + FlowRow( + modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NewConstraintButton( + onClick = onNewConstraintClick, + showText = constraints.isEmpty(), + ) + + for (constraint in constraints) { + val contentColor = when (constraint) { + is ComposeChipModel.Error -> MaterialTheme.colorScheme.onErrorContainer + is ComposeChipModel.Normal -> MaterialTheme.colorScheme.onSurface + } + CompositionLocalProvider(LocalContentColor provides contentColor) { + ConstraintButton( + text = constraint.text, + isError = constraint is ComposeChipModel.Error, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + icon = { + when (constraint) { + is ComposeChipModel.Normal -> { + if (constraint.icon is ComposeIconInfo.Vector) { + Icon( + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp), + imageVector = constraint.icon.imageVector, + contentDescription = null, + ) + } else if (constraint.icon is ComposeIconInfo.Drawable) { + Icon( + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter(constraint.icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + + is ComposeChipModel.Error -> { + Icon( + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp), + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + ) + } + } + }, + ) + } + } + } +} + +@Composable +private fun NewConstraintButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + showText: Boolean = true, +) { + 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, + ) { + 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 = {}, + isError: Boolean, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + val color = if (isError) { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f) + } + + Surface( + modifier = modifier.height(28.dp), + shape = MaterialTheme.shapes.small, + color = color, + ) { + 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) { + 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/GroupRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt index 4a10b82b4d..494d20e6fa 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -83,18 +83,22 @@ fun GroupRow( when (group.icon) { is ComposeIconInfo.Drawable -> { Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), painter = rememberDrawablePainter(group.icon.drawable), contentDescription = null, - modifier = Modifier.size(24.dp), tint = Color.Unspecified, ) } is ComposeIconInfo.Vector -> { Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), imageVector = group.icon.imageVector, contentDescription = null, - modifier = Modifier.size(24.dp), ) } @@ -181,7 +185,7 @@ private fun GroupButton( verticalAlignment = Alignment.CenterVertically, ) { icon() - Spacer(modifier = Modifier.width(8.dp)) + Text( text = text, maxLines = 1, 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 index eb17e9b0b8..b4c836fa96 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -190,6 +190,9 @@ fun HomeKeyMapListScreen( onEditGroupNameClick = viewModel::onEditGroupNameClick, onGroupClick = viewModel::onGroupClick, onDeleteGroupClick = viewModel::onDeleteGroupClick, + onNewConstraintClick = viewModel::onNewGroupConstraintClick, + onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, + onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, ) }, selectionBottomSheet = { 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 index d62d353116..b9af79088e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -36,7 +36,6 @@ 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.KeyboardArrowRight import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.rounded.Delete @@ -59,7 +58,6 @@ import androidx.compose.material3.HorizontalDivider 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.OutlinedButton import androidx.compose.material3.OutlinedTextFieldDefaults @@ -70,7 +68,6 @@ import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -95,11 +92,14 @@ 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.GroupRow import io.github.sds100.keymapper.groups.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState @@ -133,6 +133,9 @@ fun KeyMapAppBar( isEditingGroupName: Boolean = false, onEditGroupNameClick: () -> Unit = {}, onDeleteGroupClick: () -> Unit = {}, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onConstraintModeChanged: (ConstraintMode) -> Unit = {}, ) { BackHandler(onBack = onBackClick) @@ -214,6 +217,11 @@ fun KeyMapAppBar( subGroups = state.subGroups, parentGroups = state.parentGroups, onGroupClick = onGroupClick, + constraints = state.constraints, + constraintMode = state.constraintMode, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + onConstraintModeChanged = onConstraintModeChanged, actions = { AnimatedVisibility(!isEditingGroupName) { AppBarActions( @@ -343,6 +351,11 @@ private fun ChildGroupAppBar( parentGroups: List, onNewGroupClick: () -> Unit = {}, onGroupClick: (String?) -> Unit = {}, + constraints: List = emptyList(), + constraintMode: ConstraintMode, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onConstraintModeChanged: (ConstraintMode) -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { // Make custom top app bar because the height can not be set to fix the text field error in. @@ -390,33 +403,44 @@ private fun ChildGroupAppBar( AnimatedVisibility(!isEditingGroupName) { Column { + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(R.string.home_group_constraints_title), + style = MaterialTheme.typography.titleSmall, + ) + + // TODO constraint mode + GroupConstraintRow( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + constraints = constraints, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + ) + + HorizontalDivider() + GroupRow( modifier = Modifier - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + .padding(8.dp) .fillMaxWidth(), groups = subGroups, onNewGroupClick = onNewGroupClick, onGroupClick = onGroupClick, ) - HorizontalDivider() - val scrollState = rememberScrollState() LaunchedEffect(parentGroups) { scrollState.animateScrollTo(scrollState.maxValue) } - BreadcrumbRow( + GroupBreadcrumbRow( modifier = Modifier .horizontalScroll(scrollState) .fillMaxWidth() - .padding( - start = 8.dp, - end = 8.dp, - bottom = 8.dp, - top = 8.dp, - ), + .padding(8.dp), groups = parentGroups, onGroupClick = onGroupClick, ) @@ -426,63 +450,6 @@ private fun ChildGroupAppBar( } } -@Composable -private fun BreadcrumbRow( - modifier: Modifier = Modifier, - groups: List, - onGroupClick: (String?) -> Unit, -) { - Row(modifier = modifier) { - val color = LocalContentColor.current.copy(alpha = 0.6f) - Breadcrumb( - text = stringResource(R.string.home_groups_breadcrumb_home), - onClick = { onGroupClick(null) }, - color = color, - ) - - 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 - }, - ) - } - } -} - -@Composable -private fun Breadcrumb( - modifier: Modifier = Modifier, - text: String, - color: Color, - onClick: () -> Unit, -) { - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - Surface( - modifier = modifier, - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = Color.Transparent, - ) { - Text( - modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), - text = text, - style = MaterialTheme.typography.labelMedium, - color = color, - maxLines = 1, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SelectingAppBar( @@ -841,18 +808,23 @@ private fun AppBarDropdownMenu( @Composable private fun constraintsSampleList(): List { - val context = LocalContext.current + val ctx = LocalContext.current return listOf( ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), - "Key Mapper is not open", + 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 = "1", - "Key Mapper is playing media", - error = Error.AppNotFound(""), + id = "2", + text = "Key Mapper not found", + error = Error.AppNotFound(Constants.PACKAGE_NAME), ), ) } @@ -903,7 +875,7 @@ private fun KeyMapsChildGroupDarkPreview() { val state = KeyMapAppBarState.ChildGroup( groupName = "Short name", subGroups = groupSampleList(), - constraints = constraintsSampleList(), + constraints = emptyList(), constraintMode = ConstraintMode.AND, parentGroups = emptyList(), ) @@ -981,6 +953,8 @@ private fun KeyMapsChildGroupErrorPreview() { isEditingGroupName = true, subGroups = emptyList(), parentGroups = emptyList(), + constraints = emptyList(), + constraintMode = ConstraintMode.AND, ) } } 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 f3290c07ca..fe401da024 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 @@ -9,6 +9,7 @@ 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.groups.SubGroupListModel import io.github.sds100.keymapper.home.HomeWarningListItem import io.github.sds100.keymapper.home.SelectedKeyMapsEnabled @@ -685,6 +686,29 @@ class KeyMapListViewModel( } } + 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 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 b9f299e813..5264de811e 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 @@ -5,6 +5,10 @@ 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 @@ -91,6 +95,29 @@ class ListKeyMapsUseCaseImpl( return true } + private suspend fun ensureUniqueName( + entity: GroupEntity, + block: suspend (entity: GroupEntity) -> Unit, + ): GroupEntity { + var group = entity + var count = 0 + + while (true) { + // Insert must be suspending so we only update the layout uid once the layout + // has been saved. + try { + block(group) + break + } catch (_: SQLiteConstraintException) { + // If the name already exists try creating it with a new name. + group = group.copy(name = "${entity.name} (${count + 1})") + count++ + } + } + + return group + } + override suspend fun openGroup(uid: String?) { if (uid == null) { // If null then open the root group. @@ -127,27 +154,55 @@ class ListKeyMapsUseCaseImpl( } } - private suspend fun ensureUniqueName( - entity: GroupEntity, - block: suspend (entity: GroupEntity) -> Unit, - ): GroupEntity { - var group = entity - var count = 0 + 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), + ) - while (true) { - // Insert must be suspending so we only update the layout uid once the layout - // has been saved. try { - block(group) - break + groupRepository.update(groupEntity) } catch (_: SQLiteConstraintException) { - // If the name already exists try creating it with a new name. - group = group.copy(name = "${entity.name} (${count + 1})") - count++ + return } } + } - return group + 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 + } + } } @OptIn(ExperimentalCoroutinesApi::class) @@ -262,6 +317,9 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { 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 deleteKeyMap(vararg uid: String) fun enableKeyMap(vararg uid: String) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36463d8ae4..c306dd642d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1376,7 +1376,8 @@ View all Hide Group constraints - New group constraint + New constraint + Delete group constraint Remove From c0d20e8db0c65d9399e2c841e3cf378746dc614e Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 19:32:36 -0600 Subject: [PATCH 52/94] #320 tweak groups --- .../keymapper/groups/GroupBreadcrumbRow.kt | 7 +- .../keymapper/groups/GroupConstraintRow.kt | 162 ++++++++++++------ .../sds100/keymapper/groups/GroupRow.kt | 123 ++++++++----- .../keymapper/home/HomeKeyMapListScreen.kt | 1 + .../sds100/keymapper/home/KeyMapAppBar.kt | 147 ++++++++-------- .../mappings/keymaps/KeyMapListItemCreator.kt | 2 +- 6 files changed, 281 insertions(+), 161 deletions(-) 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 index 0a7e237e67..6f3ecaf888 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt @@ -23,13 +23,15 @@ fun GroupBreadcrumbRow( modifier: Modifier = Modifier, groups: List, onGroupClick: (String?) -> Unit, + enabled: Boolean = true, ) { Row(modifier = modifier) { - val color = LocalContentColor.current.copy(alpha = 0.6f) + 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()) { @@ -43,6 +45,7 @@ fun GroupBreadcrumbRow( } else { color }, + enabled = enabled, ) } } @@ -54,6 +57,7 @@ private fun Breadcrumb( text: String, color: Color, onClick: () -> Unit, + enabled: Boolean, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, @@ -63,6 +67,7 @@ private fun Breadcrumb( onClick = onClick, shape = MaterialTheme.shapes.small, color = Color.Transparent, + enabled = enabled, ) { Text( modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), 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 index 9f24c9fbaa..0bad730b10 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -47,6 +47,8 @@ fun GroupConstraintRow( constraints: List, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, + onFixConstraintClick: (Error) -> Unit = {}, + enabled: Boolean = true, ) { FlowRow( modifier.verticalScroll(rememberScrollState()), @@ -56,53 +58,58 @@ fun GroupConstraintRow( NewConstraintButton( onClick = onNewConstraintClick, showText = constraints.isEmpty(), + enabled = enabled, ) for (constraint in constraints) { - val contentColor = when (constraint) { - is ComposeChipModel.Error -> MaterialTheme.colorScheme.onErrorContainer - is ComposeChipModel.Normal -> MaterialTheme.colorScheme.onSurface - } - CompositionLocalProvider(LocalContentColor provides contentColor) { - ConstraintButton( - text = constraint.text, - isError = constraint is ComposeChipModel.Error, - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - icon = { - when (constraint) { - is ComposeChipModel.Normal -> { - if (constraint.icon is ComposeIconInfo.Vector) { - Icon( - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp), - imageVector = constraint.icon.imageVector, - contentDescription = null, - ) - } else if (constraint.icon is ComposeIconInfo.Drawable) { - Icon( - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp), - painter = rememberDrawablePainter(constraint.icon.drawable), - contentDescription = null, - tint = Color.Unspecified, - ) + 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 = { + when (constraint) { + is ComposeChipModel.Normal -> { + if (constraint.icon is ComposeIconInfo.Vector) { + Icon( + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp), + imageVector = constraint.icon.imageVector, + contentDescription = null, + ) + } else if (constraint.icon is ComposeIconInfo.Drawable) { + Icon( + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter(constraint.icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + + is ComposeChipModel.Error -> { + } } - } - - is ComposeChipModel.Error -> { - Icon( - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp), - imageVector = Icons.Rounded.ErrorOutline, - contentDescription = null, - ) - } - } - }, - ) + }, + ) + } + + 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, + ) + } } } } @@ -113,6 +120,7 @@ private fun NewConstraintButton( modifier: Modifier = Modifier, onClick: () -> Unit, showText: Boolean = true, + enabled: Boolean, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, @@ -123,6 +131,7 @@ private fun NewConstraintButton( 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), @@ -152,27 +161,74 @@ private fun ConstraintButton( text: String, icon: @Composable () -> Unit, onRemoveClick: () -> Unit = {}, - isError: Boolean, + enabled: Boolean, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, ) { - val color = if (isError) { - MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f) + 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 = color, + 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() + Icon( + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp), + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + ) Text( text = text, @@ -182,7 +238,11 @@ private fun ConstraintButton( Spacer(modifier = Modifier.width(4.dp)) - IconButton(modifier = Modifier.size(16.dp), onClick = onRemoveClick) { + IconButton( + modifier = Modifier.size(16.dp), + onClick = onRemoveClick, + enabled = enabled, + ) { Icon( imageVector = Icons.Rounded.Close, contentDescription = stringResource(R.string.home_group_delete_constraint_button), 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 index 494d20e6fa..0a2b76a069 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -16,10 +16,12 @@ 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 @@ -43,6 +45,7 @@ fun GroupRow( groups: List, onNewGroupClick: () -> Unit = {}, onGroupClick: (String) -> Unit = {}, + enabled: Boolean = true, ) { var viewAllState by rememberSaveable { mutableStateOf(false) } FlowRow( @@ -62,6 +65,7 @@ fun GroupRow( Icon(imageVector = Icons.Rounded.Add, null) }, showText = groups.isEmpty(), + enabled = enabled, ) if (groups.isNotEmpty()) { @@ -72,6 +76,7 @@ fun GroupRow( } else { stringResource(R.string.home_new_view_all_groups_button) }, + enabled = enabled, ) } @@ -79,6 +84,7 @@ fun GroupRow( GroupButton( onClick = { onGroupClick(group.uid) }, text = group.name, + enabled = enabled, icon = { when (group.icon) { is ComposeIconInfo.Drawable -> { @@ -117,27 +123,38 @@ private fun NewGroupButton( text: String, icon: @Composable () -> Unit, showText: Boolean = true, + enabled: Boolean, ) { - Surface( - modifier = modifier.height(36.dp), - onClick = onClick, - shape = MaterialTheme.shapes.medium, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), - color = Color.Transparent, + val color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + CompositionLocalProvider( + LocalContentColor provides color, ) { - Row( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = modifier.height(36.dp), + onClick = onClick, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, color = color), + color = Color.Transparent, + enabled = enabled, ) { - icon() + 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, - color = MaterialTheme.colorScheme.onSurface, - ) + if (showText) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + } } } } @@ -148,21 +165,32 @@ private fun ViewAllButton( modifier: Modifier = Modifier, onClick: () -> Unit, text: String, + enabled: Boolean, ) { - Surface( - modifier = modifier.height(36.dp), - onClick = onClick, - shape = MaterialTheme.shapes.medium, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), - color = Color.Transparent, + val color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + CompositionLocalProvider( + LocalContentColor provides color, ) { - AnimatedContent(text) { text -> - Text( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) + 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, + ) + } } } } @@ -173,12 +201,14 @@ private fun GroupButton( 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), @@ -200,7 +230,19 @@ private fun GroupButton( @Composable private fun PreviewEmpty() { KeyMapperTheme { - GroupRow(groups = emptyList()) + Surface { + GroupRow(groups = emptyList()) + } + } +} + +@Preview +@Composable +private fun PreviewEmptyDisabled() { + KeyMapperTheme { + Surface { + GroupRow(groups = emptyList(), enabled = false) + } } } @@ -208,15 +250,18 @@ private fun PreviewEmpty() { @Composable private fun PreviewOneItem() { KeyMapperTheme { - GroupRow( - groups = listOf( - SubGroupListModel( - uid = "1", - name = "Device is locked", - icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + Surface { + GroupRow( + groups = listOf( + SubGroupListModel( + uid = "1", + name = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), ), - ), - ) + enabled = false, + ) + } } } 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 index b4c836fa96..14602263d8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -193,6 +193,7 @@ fun HomeKeyMapListScreen( onNewConstraintClick = viewModel::onNewGroupConstraintClick, onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, + onFixConstraintClick = viewModel::onFixClick, ) }, selectionBottomSheet = { 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 index b9af79088e..05e9934383 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -54,7 +54,6 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -136,10 +135,13 @@ fun KeyMapAppBar( onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, + onFixConstraintClick: (Error) -> Unit = {}, ) { BackHandler(onBack = onBackClick) - AnimatedContent(state) { state -> + // 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, @@ -178,6 +180,10 @@ fun KeyMapAppBar( var newName by remember { mutableStateOf(TextFieldValue(state.groupName)) } + LaunchedEffect(state.groupName) { + newName = TextFieldValue(state.groupName) + } + var showDeleteGroupDialog by remember { mutableStateOf(false) } LaunchedEffect(isEditingGroupName) { @@ -222,6 +228,7 @@ fun KeyMapAppBar( onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, onConstraintModeChanged = onConstraintModeChanged, + onFixConstraintClick = onFixConstraintClick, actions = { AnimatedVisibility(!isEditingGroupName) { AppBarActions( @@ -356,58 +363,55 @@ private fun ChildGroupAppBar( 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. - 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), - ) - } - - Spacer(Modifier.width(8.dp)) - - Spacer(Modifier.width(8.dp)) + 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, - ) + GroupNameRow( + modifier = Modifier.weight(1f), + value = groupName, + onValueChange = onValueChange, + placeholder = placeholder, + onRenameClick = onRenameClick, + error = error, + isEditing = isEditingGroupName, + onEditClick = onEditClick, + ) - AnimatedVisibility(visible = !isEditingGroupName) { - actions() + AnimatedVisibility(visible = !isEditingGroupName) { + actions() + } } - } - AnimatedVisibility(!isEditingGroupName) { Column { - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = stringResource(R.string.home_group_constraints_title), - style = MaterialTheme.typography.titleSmall, - ) + // Text( + // modifier = Modifier.padding(horizontal = 8.dp), + // text = stringResource(R.string.home_group_constraints_title), + // style = MaterialTheme.typography.titleSmall, + // ) // TODO constraint mode GroupConstraintRow( @@ -415,36 +419,41 @@ private fun ChildGroupAppBar( .padding(8.dp) .fillMaxWidth(), constraints = constraints, + onFixConstraintClick = onFixConstraintClick, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, + enabled = !isEditingGroupName, ) + } + } + } - HorizontalDivider() - - GroupRow( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), - groups = subGroups, - onNewGroupClick = onNewGroupClick, - onGroupClick = onGroupClick, - ) - - val scrollState = rememberScrollState() + Surface { + Column { + GroupRow( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + enabled = !isEditingGroupName, + ) - LaunchedEffect(parentGroups) { - scrollState.animateScrollTo(scrollState.maxValue) - } + val scrollState = rememberScrollState() - GroupBreadcrumbRow( - modifier = Modifier - .horizontalScroll(scrollState) - .fillMaxWidth() - .padding(8.dp), - groups = parentGroups, - onGroupClick = onGroupClick, - ) + LaunchedEffect(parentGroups) { + scrollState.animateScrollTo(scrollState.maxValue) } + + GroupBreadcrumbRow( + modifier = Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .padding(8.dp), + groups = parentGroups, + onGroupClick = onGroupClick, + ) } } } 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 735b3b252e..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 @@ -175,7 +175,7 @@ class KeyMapListItemCreator( icon = icon, ) } else { - ComposeChipModel.Error(constraint.uid, text, error) + ComposeChipModel.Error(constraint.uid, text, error, error.isFixable) } yield(chip) From bec1eb885b6c68b4156de8d11f320d3a2ced842c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 19:47:26 -0600 Subject: [PATCH 53/94] add uid to constraints --- .../constraints/ChooseConstraintViewModel.kt | 80 ++-- .../keymapper/constraints/Constraint.kt | 344 +++++++++++++----- .../constraints/ConstraintSnapshot.kt | 34 +- .../constraints/ConstraintUiHelper.kt | 34 +- .../data/entities/ConstraintEntity.kt | 21 +- .../keymapper/groups/GroupConstraintRow.kt | 44 +-- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 4 +- .../KeyMapConstraintsComparator.kt | 34 +- 8 files changed, 399 insertions(+), 196 deletions(-) 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..6142c265e4 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) { 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/entities/ConstraintEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index de3f8fe31c..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 @@ -2,10 +2,12 @@ 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. @@ -18,14 +20,22 @@ data class ConstraintEntity( @SerializedName(NAME_EXTRAS) val extras: List, + + @SerializedName(NAME_UID) + val uid: String, ) : Parcelable { - constructor(type: String, vararg extra: EntityExtra) : this(type, extra.toList()) + 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 @@ -89,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/groups/GroupConstraintRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt index 0bad730b10..9bf5d11e2a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -71,32 +71,26 @@ fun GroupConstraintRow( // Only allow clicking on error chips enabled = enabled, icon = { - when (constraint) { - is ComposeChipModel.Normal -> { - if (constraint.icon is ComposeIconInfo.Vector) { - Icon( - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp), - imageVector = constraint.icon.imageVector, - contentDescription = null, - ) - } else if (constraint.icon is ComposeIconInfo.Drawable) { - Icon( - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp), - painter = rememberDrawablePainter(constraint.icon.drawable), - contentDescription = null, - tint = Color.Unspecified, - ) - } - } - - is ComposeChipModel.Error -> { - } + 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, + ) } }, + ) } @@ -224,7 +218,7 @@ private fun ConstraintErrorButton( ) { Icon( modifier = Modifier - .size(20.dp) + .size(24.dp) .padding(end = 8.dp), imageVector = Icons.Rounded.ErrorOutline, contentDescription = null, 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 ffd1bd3a3d..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( 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("") } } } From b647af5b39a2cb8f88d50707a6192b27daee0ee8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 20:01:32 -0600 Subject: [PATCH 54/94] #320 more group tweaks --- .../sds100/keymapper/home/KeyMapAppBar.kt | 47 +++++++++++++++---- .../util/ui/compose/RadioButtonText.kt | 7 ++- 2 files changed, 43 insertions(+), 11 deletions(-) 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 index 05e9934383..ab003a1cb0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -106,6 +106,7 @@ 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 @@ -175,20 +176,19 @@ fun KeyMapAppBar( 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 } - var showDeleteGroupDialog by remember { mutableStateOf(false) } - LaunchedEffect(isEditingGroupName) { if (isEditingGroupName) { - newName = newName.copy(selection = TextRange(0, newName.text.length)) + newName = newName.copy(selection = TextRange(0, state.groupName.length)) } } @@ -202,7 +202,11 @@ fun KeyMapAppBar( ChildGroupAppBar( modifier = modifier, - groupName = newName, + groupName = if (isEditingGroupName) { + newName + } else { + TextFieldValue(state.groupName) + }, placeholder = state.groupName, error = error, onValueChange = { @@ -333,7 +337,7 @@ private fun RootGroupAppBar( Surface(color = appBarContainerColor) { GroupRow( modifier = Modifier - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + .padding(8.dp) .fillMaxWidth(), groups = state.subGroups, onNewGroupClick = onNewGroupClick, @@ -406,17 +410,16 @@ private fun ChildGroupAppBar( } } - Column { + Column(horizontalAlignment = Alignment.End) { // Text( // modifier = Modifier.padding(horizontal = 8.dp), // text = stringResource(R.string.home_group_constraints_title), // style = MaterialTheme.typography.titleSmall, // ) - // TODO constraint mode GroupConstraintRow( modifier = Modifier - .padding(8.dp) + .padding(horizontal = 8.dp) .fillMaxWidth(), constraints = constraints, onFixConstraintClick = onFixConstraintClick, @@ -424,6 +427,30 @@ private fun ChildGroupAppBar( 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) + }, + ) + } + } } } } 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, From 3ca7d758aec840cb194187e3b52f17aebe8f1cb2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 20:09:35 -0600 Subject: [PATCH 55/94] #320 only show view all button if necessary --- .../sds100/keymapper/groups/GroupRow.kt | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) 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 index 0a2b76a069..871d1ca539 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -1,9 +1,12 @@ 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 @@ -48,8 +51,12 @@ fun GroupRow( enabled: Boolean = true, ) { var viewAllState by rememberSaveable { mutableStateOf(false) } + + @OptIn(ExperimentalLayoutApi::class) FlowRow( - modifier.verticalScroll(rememberScrollState()), + modifier + .verticalScroll(rememberScrollState()) + .animateContentSize(), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), maxLines = if (viewAllState) { @@ -57,6 +64,23 @@ fun GroupRow( } 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, @@ -68,18 +92,6 @@ fun GroupRow( enabled = enabled, ) - if (groups.isNotEmpty()) { - ViewAllButton( - onClick = { viewAllState = !viewAllState }, - text = if (viewAllState) { - stringResource(R.string.home_new_hide_groups_button) - } else { - stringResource(R.string.home_new_view_all_groups_button) - }, - enabled = enabled, - ) - } - for (group in groups) { GroupButton( onClick = { onGroupClick(group.uid) }, From eb8814f2b341ec3d8d03007488a16010051aa47d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 20:12:38 -0600 Subject: [PATCH 56/94] #320 show icons for groups based off the first constraint --- .../mappings/keymaps/KeyMapListViewModel.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 fe401da024..dee56f25e9 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 @@ -10,6 +10,7 @@ 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.SubGroupListModel import io.github.sds100.keymapper.home.HomeWarningListItem import io.github.sds100.keymapper.home.SelectedKeyMapsEnabled @@ -46,6 +47,7 @@ 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 @@ -91,6 +93,7 @@ class KeyMapListViewModel( val multiSelectProvider: MultiSelectProvider = MultiSelectProvider() private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) + private val constraintUiHelper = ConstraintUiHelper(listKeyMaps, resourceProvider) private val initialState = KeyMapListState( appBarState = KeyMapAppBarState.RootGroup( @@ -302,10 +305,17 @@ class KeyMapListViewModel( ) } else { val subGroupListItems = keyMapGroup.subGroups.map { group -> + var icon: ComposeIconInfo? = null + + val constraint = group.constraintState.constraints.firstOrNull() + if (constraint != null) { + icon = constraintUiHelper.getIcon(constraint) + } + SubGroupListModel( uid = group.uid, name = group.name, - icon = null, // TODO show icon depending on constraints + icon = icon, ) } From 231dde6ace500b7a609c2467e31796008938f09f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 21:00:53 -0600 Subject: [PATCH 57/94] #320 test testing multiple sets of constraints --- .../constraints/ConstraintSnapshot.kt | 45 +++- .../constraints/ConstraintSnapshotTest.kt | 206 ++++++++++++++++++ .../mappings/keymaps/KeyMapControllerTest.kt | 14 +- .../keymapper/util/TestConstraintSnapshot.kt | 34 +-- 4 files changed, 264 insertions(+), 35 deletions(-) create mode 100644 app/src/test/java/io/github/sds100/keymapper/constraints/ConstraintSnapshotTest.kt 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 6142c265e4..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 @@ -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/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..6412adf546 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 @@ -950,12 +950,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 +973,7 @@ class KeyMapControllerTest { ) // Only the short press trigger is allowed. - mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn } + mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn() } mockTriggerKeyInput(shortPressTrigger.keys.first()) @@ -989,12 +989,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 +1012,7 @@ class KeyMapControllerTest { ) // Only the short press trigger is allowed. - mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn } + mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn() } mockTriggerKeyInput(shortPressTrigger.keys.first()) @@ -1109,7 +1109,7 @@ class KeyMapControllerTest { ), actionList = listOf(Action(data = actionData)), constraintState = ConstraintState( - constraints = setOf(Constraint.FlashlightOn(CameraLens.BACK)), + constraints = setOf(Constraint.FlashlightOn(lens = CameraLens.BACK)), ), ) 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) { From 466df3faa89ee4b21d50b43fa8aa08e79d9f28e0 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 22:03:05 -0600 Subject: [PATCH 58/94] #320 test combining key maps with their group constraints --- .../io/github/sds100/keymapper/UseCases.kt | 1 + .../sds100/keymapper/data/db/dao/GroupDao.kt | 3 + .../data/repositories/GroupRepository.kt | 9 + .../keymaps/detection/DetectKeyMapModel.kt | 9 + .../keymaps/detection/DetectKeyMapsUseCase.kt | 74 ++++- .../BaseAccessibilityServiceController.kt | 4 +- .../mappings/keymaps/KeyMapControllerTest.kt | 24 +- .../ProcessKeyMapGroupsForDetectionTest.kt | 295 ++++++++++++++++++ 8 files changed, 395 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapModel.kt create mode 100644 app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ProcessKeyMapGroupsForDetectionTest.kt 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 47dfd58f7f..9abb648489 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -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/data/db/dao/GroupDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt index a4bd84343c..5401f87836 100644 --- 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 @@ -22,6 +22,9 @@ interface GroupDao { 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 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 index 9196376161..026482ded9 100644 --- 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 @@ -8,11 +8,16 @@ 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? suspend fun getGroups(vararg uid: String): Flow> @@ -28,6 +33,10 @@ class RoomGroupRepository( 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()) } 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/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 254fb6896e..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 @@ -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/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 6412adf546..e458cda762 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 @@ -13,6 +13,7 @@ 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 +40,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 +126,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 +137,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 @@ -973,7 +982,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()) @@ -1012,7 +1022,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()) @@ -4101,11 +4112,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, + ) + } +} From 389b919d68f0a20be2dd068b325625558cface64 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 22:25:58 -0600 Subject: [PATCH 59/94] #320 mixing key map and group constraints works --- .../typeconverter/ActionListTypeConverter.kt | 7 +- .../ConstraintListTypeConverter.kt | 10 +- .../typeconverter/ExtraListTypeConverter.kt | 8 +- .../db/typeconverter/TriggerTypeConverter.kt | 15 ++- .../mappings/keymaps/KeyMapControllerTest.kt | 95 +++++++++++++++++++ 5 files changed, 117 insertions(+), 18 deletions(-) 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/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index e458cda762..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,6 +8,7 @@ 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 @@ -200,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 */ From c01f0db0fdb40f5e03c34f18f0eeacd1d3c2917f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 22:37:08 -0600 Subject: [PATCH 60/94] fix foss code --- .../home/HomeFloatingLayoutsScreen.kt | 16 + .../keymaps/detection/KeyMapController.kt | 479 +++++++++--------- .../AccessibilityServiceController.kt | 6 +- .../github/sds100/keymapper/KeyMapperApp.kt | 4 +- .../io/github/sds100/keymapper/UseCases.kt | 2 +- .../api/PauseMappingsBroadcastReceiver.kt | 2 +- .../keymapper/home/SelectionBottomSheet.kt | 1 + .../system/tiles/ToggleMappingsTile.kt | 2 +- .../io/github/sds100/keymapper/util/Inject.kt | 6 +- 9 files changed, 262 insertions(+), 256 deletions(-) create mode 100644 app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt 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..4c7350dfc3 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.home + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import io.github.sds100.keymapper.floating.ListFloatingLayoutsViewModel + +@Composable +fun HomeFloatingLayoutsScreen( + modifier: Modifier = Modifier, + viewModel: ListFloatingLayoutsViewModel, + navController: NavHostController, + snackbarState: SnackbarHostState, +) { +} 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/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index 9abb648489..22e395491a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -97,7 +97,7 @@ object UseCases { fun fingerprintGesturesSupported(ctx: Context) = FingerprintGesturesSupportedUseCaseImpl(ServiceLocator.settingsRepository(ctx)) - fun pauseMappings(ctx: Context) = PauseKeyMapsUseCaseImpl( + fun pauseKeyMaps(ctx: Context) = PauseKeyMapsUseCaseImpl( ServiceLocator.settingsRepository(ctx), ServiceLocator.mediaAdapter(ctx), ) 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/home/SelectionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt index 9be0f9acc3..4ba3265bc8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R +// TODO add move to group @Composable fun SelectionBottomSheet( modifier: Modifier = Modifier, 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 7d4bc791ad..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 @@ -160,7 +160,7 @@ object Inject { ServiceLocator.resourceProvider(ctx), UseCases.displayKeyMap(ctx), ), - UseCases.pauseMappings(ctx), + UseCases.pauseKeyMaps(ctx), BackupRestoreMappingsUseCaseImpl( ServiceLocator.fileAdapter(ctx), ServiceLocator.backupManager(ctx), @@ -169,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), @@ -218,7 +218,7 @@ object Inject { keyEventRelayService = keyEventRelayService, ), fingerprintGesturesSupportedUseCase = UseCases.fingerprintGesturesSupported(service), - pauseKeyMapsUseCase = UseCases.pauseMappings(service), + pauseKeyMapsUseCase = UseCases.pauseKeyMaps(service), devicesAdapter = ServiceLocator.devicesAdapter(service), suAdapter = ServiceLocator.suAdapter(service), rerouteKeyEventsUseCase = UseCases.rerouteKeyEvents( From 27215b9af809c9fd8d6c15f3f0e33cc7bb9af92d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 22:43:33 -0600 Subject: [PATCH 61/94] fix: remove bottom padding from FAB if no bottom nav bar showing --- .../keymapper/home/HomeFloatingLayoutsScreen.kt | 2 ++ .../sds100/keymapper/home/HomeKeyMapListScreen.kt | 5 +++-- .../io/github/sds100/keymapper/home/HomeScreen.kt | 12 ++++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) 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 index 4c7350dfc3..aca43b669c 100644 --- a/app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt +++ b/app/src/free/java/io/github/sds100/keymapper/home/HomeFloatingLayoutsScreen.kt @@ -3,6 +3,7 @@ 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 @@ -12,5 +13,6 @@ fun HomeFloatingLayoutsScreen( viewModel: ListFloatingLayoutsViewModel, navController: NavHostController, snackbarState: SnackbarHostState, + fabBottomPadding: Dp, ) { } 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 index 14602263d8..e9cb8716ff 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -39,7 +39,7 @@ 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 @@ -73,6 +73,7 @@ fun HomeKeyMapListScreen( onSettingsClick: () -> Unit, onAboutClick: () -> Unit, finishActivity: () -> Unit, + fabBottomPadding: Dp ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -146,7 +147,7 @@ fun HomeKeyMapListScreen( exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }), ) { CollapsableFloatingActionButton( - modifier = Modifier.padding(bottom = 80.dp), + modifier = Modifier.padding(bottom = fabBottomPadding), onClick = viewModel::onNewKeyMapClick, showText = viewModel.showFabText, text = stringResource(R.string.home_fab_new_key_map), 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 fafd6a4728..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 @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -42,7 +41,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import io.github.sds100.keymapper.util.ui.SelectionState -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( modifier: Modifier = Modifier, @@ -71,6 +69,11 @@ fun HomeScreen( onSettingsClick = onSettingsClick, onAboutClick = onAboutClick, finishActivity = finishActivity, + fabBottomPadding = if (navBarItems.size == 1) { + 0.dp + } else { + 80.dp + }, ) }, floatingButtonsContent = { @@ -78,6 +81,11 @@ fun HomeScreen( viewModel = viewModel.listFloatingLayoutsViewModel, navController = navController, snackbarState = snackbarState, + fabBottomPadding = if (navBarItems.size == 1) { + 0.dp + } else { + 80.dp + }, ) }, ) From b1dd809fb7478fcfa071f1a3e2481a6330b44a9e Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 30 Mar 2025 23:28:55 -0600 Subject: [PATCH 62/94] #320 move key maps to another group --- .../actions/ConfigActionsViewModel.kt | 2 +- .../sds100/keymapper/data/db/dao/KeyMapDao.kt | 3 + .../data/repositories/RoomKeyMapRepository.kt | 22 +- .../keymapper/groups/GroupBreadcrumbRow.kt | 2 +- ...roupListModel.kt => GroupListItemModel.kt} | 2 +- .../sds100/keymapper/groups/GroupRow.kt | 10 +- .../keymapper/home/DeleteKeyMapsDialog.kt | 4 +- .../keymapper/home/HomeKeyMapListScreen.kt | 12 +- .../sds100/keymapper/home/HomeViewModel.kt | 11 - .../sds100/keymapper/home/HomeWarningList.kt | 4 +- .../sds100/keymapper/home/ImportDialog.kt | 2 +- .../sds100/keymapper/home/KeyMapAppBar.kt | 16 +- .../keymapper/home/SelectionBottomSheet.kt | 112 +++++++-- .../keymaps/CreateKeyMapShortcutViewModel.kt | 21 +- .../keymapper/mappings/keymaps/KeyMap.kt | 2 +- .../mappings/keymaps/KeyMapAppBarState.kt | 9 +- .../mappings/keymaps/KeyMapListViewModel.kt | 225 ++++++++++++------ .../mappings/keymaps/KeyMapRepository.kt | 1 + .../mappings/keymaps/ListKeyMapsUseCase.kt | 103 ++++---- .../Android11BugWorkaroundSettingsFragment.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 21 files changed, 382 insertions(+), 184 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/groups/{SubGroupListModel.kt => GroupListItemModel.kt} (52%) 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 a866af688d..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.util.ui.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/data/db/dao/KeyMapDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt index ae77eea382..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 @@ -53,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/repositories/RoomKeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index 23556328ce..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 @@ -58,7 +58,7 @@ class RoomKeyMapRepository( 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) } @@ -74,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) } @@ -86,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) } @@ -96,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) { @@ -113,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) } @@ -123,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) } @@ -131,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/GroupBreadcrumbRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt index 6f3ecaf888..da9ec9382f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt @@ -21,7 +21,7 @@ import io.github.sds100.keymapper.R @Composable fun GroupBreadcrumbRow( modifier: Modifier = Modifier, - groups: List, + groups: List, onGroupClick: (String?) -> Unit, enabled: Boolean = true, ) { diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/SubGroupListModel.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupListItemModel.kt similarity index 52% rename from app/src/main/java/io/github/sds100/keymapper/groups/SubGroupListModel.kt rename to app/src/main/java/io/github/sds100/keymapper/groups/GroupListItemModel.kt index b8859f6444..9f53a1da74 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/SubGroupListModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupListItemModel.kt @@ -2,4 +2,4 @@ package io.github.sds100.keymapper.groups import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo -data class SubGroupListModel(val uid: String, val name: String, val icon: ComposeIconInfo? = null) +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 index 871d1ca539..afb5d01095 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -45,7 +45,7 @@ import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo @Composable fun GroupRow( modifier: Modifier = Modifier, - groups: List, + groups: List, onNewGroupClick: () -> Unit = {}, onGroupClick: (String) -> Unit = {}, enabled: Boolean = true, @@ -265,7 +265,7 @@ private fun PreviewOneItem() { Surface { GroupRow( groups = listOf( - SubGroupListModel( + GroupListItemModel( uid = "1", name = "Device is locked", icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), @@ -286,17 +286,17 @@ private fun PreviewMultipleItems() { Surface { GroupRow( groups = listOf( - SubGroupListModel( + GroupListItemModel( uid = "1", name = "Lockscreen", icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), ), - SubGroupListModel( + GroupListItemModel( uid = "2", name = "Key Mapper", icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), ), - SubGroupListModel( + GroupListItemModel( uid = "3", name = "Key Mapper", icon = null, 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 index e388ae56f9..70d7772c34 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/DeleteKeyMapsDialog.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/DeleteKeyMapsDialog.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.res.stringResource import io.github.sds100.keymapper.R @Composable - fun DeleteKeyMapsDialog( +fun DeleteKeyMapsDialog( modifier: Modifier = Modifier, keyMapCount: Int, onDismissRequest: () -> Unit, @@ -46,4 +46,4 @@ import io.github.sds100.keymapper.R } }, ) -} \ No newline at end of file +} 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 index e9cb8716ff..f32d65727e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -47,7 +47,7 @@ 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.SubGroupListModel +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 @@ -73,7 +73,7 @@ fun HomeKeyMapListScreen( onSettingsClick: () -> Unit, onAboutClick: () -> Unit, finishActivity: () -> Unit, - fabBottomPadding: Dp + fabBottomPadding: Dp, ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -208,15 +208,19 @@ fun HomeKeyMapListScreen( selectionCount = 0, selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, isAllSelected = false, + groups = emptyList(), ) SelectionBottomSheet( 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, ) } }, @@ -460,6 +464,7 @@ private fun PreviewSelectingKeyMaps() { selectionCount = 2, selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, isAllSelected = false, + groups = emptyList(), ) val listState = State.Data(sampleList()) @@ -482,6 +487,7 @@ private fun PreviewSelectingKeyMaps() { SelectionBottomSheet( enabled = true, selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + groups = emptyList(), ) }, ) @@ -579,7 +585,7 @@ private fun PreviewKeyMapsWarnings() { val appBarState = KeyMapAppBarState.RootGroup( subGroups = listOf( - SubGroupListModel( + GroupListItemModel( uid = "0", name = "Key Mapper", icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), 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 aa0a235d29..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 @@ -11,7 +11,6 @@ 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.floating.ListFloatingLayoutsUseCase import io.github.sds100.keymapper.floating.ListFloatingLayoutsViewModel import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase @@ -20,26 +19,16 @@ 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.util.Error -import io.github.sds100.keymapper.util.Result -import io.github.sds100.keymapper.util.Success -import io.github.sds100.keymapper.util.getFullMessage import io.github.sds100.keymapper.util.ui.DialogResponse -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.navigate import io.github.sds100.keymapper.util.ui.showPopup -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.first import kotlinx.coroutines.flow.launchIn 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 index 46d78949b0..28007d2deb 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeWarningList.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeWarningList.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R @Composable - fun HomeWarningList( +fun HomeWarningList( modifier: Modifier = Modifier, warnings: List, onFixClick: (String) -> Unit, @@ -72,4 +72,4 @@ import io.github.sds100.keymapper.R } } } -} \ No newline at end of file +} 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 index e24d3305ce..0a0ba913b6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/ImportDialog.kt @@ -13,7 +13,7 @@ import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme @Composable - fun ImportDialog( +fun ImportDialog( modifier: Modifier = Modifier, keyMapCount: Int, onDismissRequest: () -> Unit, 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 index ab003a1cb0..09cb6bef3a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -99,8 +99,8 @@ 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.groups.SubGroupListModel import io.github.sds100.keymapper.mappings.keymaps.KeyMapAppBarState import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.drawable @@ -358,8 +358,8 @@ private fun ChildGroupAppBar( onEditClick: () -> Unit = {}, onRenameClick: () -> Unit = {}, isEditingGroupName: Boolean = false, - subGroups: List, - parentGroups: List, + subGroups: List, + parentGroups: List, onNewGroupClick: () -> Unit = {}, onGroupClick: (String?) -> Unit = {}, constraints: List = emptyList(), @@ -866,21 +866,21 @@ private fun constraintsSampleList(): List { } @Composable -private fun groupSampleList(): List { +private fun groupSampleList(): List { val ctx = LocalContext.current return listOf( - SubGroupListModel( + GroupListItemModel( uid = "1", name = "Lockscreen", icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), ), - SubGroupListModel( + GroupListItemModel( uid = "2", name = "Key Mapper", icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), ), - SubGroupListModel( + GroupListItemModel( uid = "3", name = "Key Mapper", icon = null, @@ -1083,6 +1083,7 @@ private fun HomeStateSelectingPreview() { selectionCount = 4, selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, isAllSelected = false, + groups = emptyList(), ) KeyMapperTheme { KeyMapAppBar(state = state) @@ -1097,6 +1098,7 @@ private fun HomeStateSelectingDisabledPreview() { 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 index 4ba3265bc8..ee90efc752 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -6,6 +6,7 @@ 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 @@ -14,11 +15,13 @@ 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 @@ -31,37 +34,45 @@ 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 -// TODO add move to group +@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 = {}, ) { - @OptIn(ExperimentalMaterial3Api::class) - ( - Surface( - modifier = modifier - .widthIn(max = BottomSheetDefaults.SheetMaxWidth) - .fillMaxWidth() - .navigationBarsPadding(), - shadowElevation = 5.dp, - shape = BottomSheetDefaults.ExpandedShape, - tonalElevation = BottomSheetDefaults.Elevation, - color = BottomSheetDefaults.ContainerColor, - ) { + 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 - .padding(16.dp) .height(intrinsicSize = IntrinsicSize.Min), ) { Row( @@ -105,8 +116,29 @@ fun SelectionBottomSheet( 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 @@ -181,3 +213,53 @@ private fun KeyMapsEnabledSwitch( ) } } + +@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/mappings/keymaps/CreateKeyMapShortcutViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt index fea1235545..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 @@ -13,13 +13,15 @@ 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.groups.SubGroupListModel +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 @@ -41,6 +43,10 @@ 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 initialState = KeyMapListState( @@ -102,15 +108,22 @@ class CreateKeyMapShortcutViewModel( } val subGroupListItems = keyMapGroup.subGroups.map { group -> - SubGroupListModel( + 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 = null, // TODO show icon depending on constraints + icon = icon, ) } val parentGroupListItems = keyMapGroup.parents.map { group -> - SubGroupListModel( + GroupListItemModel( uid = group.uid, name = group.name, icon = null, 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 c60c827dd2..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 @@ -30,7 +30,7 @@ data class KeyMap( val actionList: List = emptyList(), val constraintState: ConstraintState = ConstraintState(), val isEnabled: Boolean = true, - val groupUid: String? = null + val groupUid: String? = null, ) { val showToast: Boolean 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 index 88e8fe2c5c..8f16384238 100644 --- 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 @@ -1,14 +1,14 @@ package io.github.sds100.keymapper.mappings.keymaps import io.github.sds100.keymapper.constraints.ConstraintMode -import io.github.sds100.keymapper.groups.SubGroupListModel +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 subGroups: List = emptyList(), val warnings: List = emptyList(), val isPaused: Boolean = false, ) : KeyMapAppBarState() @@ -17,8 +17,8 @@ sealed class KeyMapAppBarState { val groupName: String, val constraints: List, val constraintMode: ConstraintMode, - val subGroups: List, - val parentGroups: List, + val subGroups: List, + val parentGroups: List, ) : KeyMapAppBarState() @@ -26,5 +26,6 @@ sealed class KeyMapAppBarState { 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/KeyMapListViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt index dee56f25e9..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 @@ -11,7 +11,8 @@ 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.SubGroupListModel +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 @@ -52,6 +53,7 @@ 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 @@ -60,7 +62,9 @@ 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 @@ -85,6 +89,8 @@ class KeyMapListViewModel( 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) @@ -235,14 +241,64 @@ class KeyMapListViewModel( } } - val appBarStateFlow = combine( + 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, - multiSelectProvider.state, pauseKeyMaps.isPaused, listKeyMaps.constraintErrorSnapshot, - transform = ::buildAppBarState, - ) + ) { 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( @@ -268,84 +324,92 @@ class KeyMapListViewModel( } } - private fun buildAppBarState( + private fun buildSelectingAppBarState( keyMapGroup: KeyMapGroup, - warnings: List, - selectionState: SelectionState, - isPaused: Boolean, - constraintErrorSnapshot: ConstraintErrorSnapshot, - ): KeyMapAppBarState { - if (selectionState is SelectionState.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 - } + 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 { - if ((keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.NONE) || - (!keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.ALL) - ) { - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED - break - } + 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, - ) - } else { - val subGroupListItems = keyMapGroup.subGroups.map { group -> - var icon: ComposeIconInfo? = null + return KeyMapAppBarState.Selecting( + selectionCount = selectionState.selectedIds.size, + selectedKeyMapsEnabled = selectedKeyMapsEnabled ?: SelectedKeyMapsEnabled.NONE, + isAllSelected = selectionState.selectedIds.size == keyMaps.size, + groups = groupListItems, + ) + } - val constraint = group.constraintState.constraints.firstOrNull() - if (constraint != null) { - icon = constraintUiHelper.getIcon(constraint) - } + private fun buildGroupAppBarState( + keyMapGroup: KeyMapGroup, + warnings: List, + isPaused: Boolean, + constraintErrorSnapshot: ConstraintErrorSnapshot, + ): KeyMapAppBarState { + val subGroupListItems = keyMapGroup.subGroups.map { group -> + buildGroupListItem(group) + } - SubGroupListModel( - uid = group.uid, - name = group.name, - icon = icon, - ) - } + val parentGroupListItems = keyMapGroup.parents.map { group -> + GroupListItemModel( + uid = group.uid, + name = group.name, + icon = null, + ) + } - val parentGroupListItems = keyMapGroup.parents.map { group -> - SubGroupListModel( - 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, + ) + } + } - 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( @@ -539,6 +603,22 @@ class 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) { @@ -690,6 +770,7 @@ class KeyMapListViewModel( fun onNewGroupClick() { coroutineScope.launch { + multiSelectProvider.stopSelecting() listKeyMaps.newGroup() isNewGroup = true isEditingGroupName = true 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 2398adb124..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 @@ -22,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 5264de811e..95964bc335 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 @@ -47,10 +47,61 @@ class ListKeyMapsUseCaseImpl( displayKeyMapUseCase: DisplayKeyMapUseCase, ) : ListKeyMapsUseCase, DisplayKeyMapUseCase by displayKeyMapUseCase { - private val groupUid = MutableStateFlow(null) private val parentGroupUids = MutableStateFlow>(emptyList()) + @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) @@ -205,52 +256,8 @@ class ListKeyMapsUseCaseImpl( } } - @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 moveKeyMapsToGroup(groupUid: String?, vararg keyMapUids: String) { + keyMapRepository.moveToGroup(groupUid, *keyMapUids) } private fun getKeyMapsByGroup(groupUid: String?): Flow>> = channelFlow { @@ -320,6 +327,8 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { 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/settings/Android11BugWorkaroundSettingsFragment.kt b/app/src/main/java/io/github/sds100/keymapper/settings/Android11BugWorkaroundSettingsFragment.kt index 996a38172d..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.util.ui.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/res/values/strings.xml b/app/src/main/res/values/strings.xml index c306dd642d..e219f3d8d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1361,6 +1361,7 @@ Enabled Disabled Mixed + Move to group Delete 1 key map From c53147ad3e48dba2b374cfd03b895dcd3e9c373c Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 00:45:11 -0600 Subject: [PATCH 63/94] #320 back up and restore groups --- .../github/sds100/keymapper/ServiceLocator.kt | 1 + .../sds100/keymapper/backup/BackupContent.kt | 5 + .../sds100/keymapper/backup/BackupManager.kt | 118 +++++++++++++++--- .../keymapper/data/entities/GroupEntity.kt | 8 +- .../keymapper/data/entities/KeyMapEntity.kt | 23 ++-- .../data/repositories/GroupRepository.kt | 5 + .../data/repositories/RepositoryUtils.kt | 29 +++++ .../mappings/keymaps/ListKeyMapsUseCase.kt | 27 ++-- .../keymapper/ConfigKeyMapUseCaseTest.kt | 10 +- 9 files changed, 172 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/repositories/RepositoryUtils.kt 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 9f8ee1366a..7404ed2235 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -172,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/backup/BackupContent.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt index 861109cd41..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,6 +3,7 @@ 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 @@ -39,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" @@ -52,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..948242ec86 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.groups.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/data/entities/GroupEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt index fbdfbc6d4c..ae13d34187 100644 --- 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 @@ -8,11 +8,11 @@ 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 io.github.sds100.keymapper.data.entities.KeyMapEntity.Companion.NAME_CONSTRAINT_LIST import kotlinx.parcelize.Parcelize import java.util.UUID @@ -63,12 +63,12 @@ data class GroupEntity( 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_CONSTRAINT_LIST) + val constraintListJsonArray by it.json.byArray(NAME_CONSTRAINTS) val constraintList = it.context.deserialize>(constraintListJsonArray) - val constraintMode by it.json.byInt(KeyMapEntity.NAME_CONSTRAINT_MODE) - val parentUid by it.json.byString(NAME_PARENT_UID) + 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/KeyMapEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt index 2215f28a07..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 @@ -9,6 +9,7 @@ import androidx.room.PrimaryKey import com.github.salomonbrys.kotson.byArray import com.github.salomonbrys.kotson.byBool import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byObject import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer @@ -71,7 +72,7 @@ data class KeyMapEntity( @ColumnInfo(name = KeyMapDao.KEY_UID) val uid: String = UUID.randomUUID().toString(), - @SerializedName(GROUP_UID) + @SerializedName(NAME_GROUP_UID) @ColumnInfo(name = KeyMapDao.KEY_GROUP_UID) val groupUid: String? = null, ) : Parcelable { @@ -86,7 +87,7 @@ data class KeyMapEntity( const val NAME_FLAGS = "flags" const val NAME_IS_ENABLED = "isEnabled" const val NAME_UID = "uid" - const val GROUP_UID = "group_uid" + const val NAME_GROUP_UID = "group_uid" val DESERIALIZER = jsonDeserializer { val actionListJsonArray by it.json.byArray(NAME_ACTION_LIST) @@ -103,16 +104,18 @@ data class KeyMapEntity( val flags by it.json.byInt(NAME_FLAGS) 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, - 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/repositories/GroupRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt index 026482ded9..639413ad64 100644 --- 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 @@ -20,6 +20,7 @@ interface GroupRepository { fun getKeyMapsByGroup(groupUid: String): Flow suspend fun getGroup(uid: String): GroupEntity? + suspend fun getAllGroups(): Flow> suspend fun getGroups(vararg uid: String): Flow> fun getGroupsByParent(uid: String?): Flow> fun getGroupWithSubGroups(uid: String): Flow @@ -45,6 +46,10 @@ class RoomGroupRepository( return withContext(dispatchers.io()) { dao.getById(uid) } } + override suspend fun getAllGroups(): Flow> { + return withContext(dispatchers.io()) { dao.getAll() } + } + override suspend fun getGroups(vararg uid: String): Flow> { return withContext(dispatchers.io()) { dao.getManyByIdFlow(*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/mappings/keymaps/ListKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt index 95964bc335..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 @@ -12,6 +12,7 @@ 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 @@ -147,26 +148,16 @@ class ListKeyMapsUseCaseImpl( } private suspend fun ensureUniqueName( - entity: GroupEntity, + group: GroupEntity, block: suspend (entity: GroupEntity) -> Unit, ): GroupEntity { - var group = entity - var count = 0 - - while (true) { - // Insert must be suspending so we only update the layout uid once the layout - // has been saved. - try { - block(group) - break - } catch (_: SQLiteConstraintException) { - // If the name already exists try creating it with a new name. - group = group.copy(name = "${entity.name} (${count + 1})") - count++ - } - } - - return group + return RepositoryUtils.saveUniqueName( + entity = group, + saveBlock = block, + renameBlock = { entity, suffix -> + entity.copy(name = "${entity.name} $suffix") + }, + ) } override suspend fun openGroup(uid: String?) { 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)), + ) } /** From 6c38254aaa8166e02bb274b83d9c4d82d6be1d01 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 01:14:26 -0600 Subject: [PATCH 64/94] #320 fix tests and other tweaks --- .../github/sds100/keymapper/backup/BackupManager.kt | 2 +- .../keymapper/data/repositories/GroupRepository.kt | 12 ++++++------ .../sds100/keymapper/home/HomeKeyMapListScreen.kt | 12 ++++++++++++ .../keymapper/mappings/keymaps/KeyMapListScreen.kt | 6 +++++- .../io/github/sds100/keymapper/BackupManagerTest.kt | 5 +++++ 5 files changed, 29 insertions(+), 8 deletions(-) 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 948242ec86..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 @@ -457,7 +457,7 @@ class BackupManagerImpl( if (backupContent.groups != null) { val groupUids = backupContent.groups.map { it.uid }.toMutableSet() - groupRepository.groups.first() + groupRepository.getAllGroups().first() .map { it.uid } .toSet() .also { groupUids.addAll(it) } 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 index 639413ad64..040095a000 100644 --- 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 @@ -20,8 +20,8 @@ interface GroupRepository { fun getKeyMapsByGroup(groupUid: String): Flow suspend fun getGroup(uid: String): GroupEntity? - suspend fun getAllGroups(): Flow> - suspend fun getGroups(vararg uid: String): Flow> + fun getAllGroups(): Flow> + fun getGroups(vararg uid: String): Flow> fun getGroupsByParent(uid: String?): Flow> fun getGroupWithSubGroups(uid: String): Flow suspend fun insert(groupEntity: GroupEntity) @@ -46,12 +46,12 @@ class RoomGroupRepository( return withContext(dispatchers.io()) { dao.getById(uid) } } - override suspend fun getAllGroups(): Flow> { - return withContext(dispatchers.io()) { dao.getAll() } + override fun getAllGroups(): Flow> { + return dao.getAll().flowOn(dispatchers.io()) } - override suspend fun getGroups(vararg uid: String): Flow> { - return withContext(dispatchers.io()) { dao.getManyByIdFlow(*uid) } + override fun getGroups(vararg uid: String): Flow> { + return dao.getManyByIdFlow(*uid).flowOn(dispatchers.io()) } override fun getGroupsByParent(uid: String?): Flow> { 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 index f32d65727e..66f50f6623 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -4,6 +4,7 @@ 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 @@ -30,16 +31,19 @@ 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 @@ -137,6 +141,8 @@ fun HomeKeyMapListScreen( 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, @@ -156,6 +162,7 @@ fun HomeKeyMapListScreen( }, listContent = { KeyMapList( + modifier = Modifier.animateContentSize(), lazyListState = rememberLazyListState(), listItems = state.listItems, footerText = stringResource(R.string.home_key_map_list_footer_text), @@ -165,6 +172,7 @@ fun HomeKeyMapListScreen( onSelectedChange = viewModel::onKeyMapSelectedChanged, onFixClick = viewModel::onFixClick, onTriggerErrorClick = viewModel::onFixTriggerError, + bottomListPadding = keyMapListBottomPadding, ) }, appBarContent = { @@ -212,6 +220,10 @@ fun HomeKeyMapListScreen( ) 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, 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 8bace163d2..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 @@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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 com.google.accompanist.drawablepainter.rememberDrawablePainter @@ -77,6 +78,7 @@ fun KeyMapList( onSelectedChange: (String, Boolean) -> Unit = { _, _ -> }, onFixClick: (Error) -> Unit = {}, onTriggerErrorClick: (TriggerError) -> Unit = {}, + bottomListPadding: Dp = 100.dp, ) { when (listItems) { is State.Loading -> { @@ -101,6 +103,7 @@ fun KeyMapList( onSelectedChange, onFixClick, onTriggerErrorClick, + bottomListPadding, ) } } @@ -149,6 +152,7 @@ private fun LoadedKeyMapList( onSelectedChange: (String, Boolean) -> Unit, onFixClick: (Error) -> Unit, onTriggerErrorClick: (TriggerError) -> Unit, + bottomListPadding: Dp, ) { val haptics = LocalHapticFeedback.current @@ -187,7 +191,7 @@ private fun LoadedKeyMapList( // 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)) } } } 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() From 65d34e6a2f981bf23c7483d17270860a4b9b2e46 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 31 Mar 2025 16:03:41 +0000 Subject: [PATCH 65/94] New Crowdin translations by GitHub Action --- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-ka/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-sk/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + 17 files changed, 17 insertions(+) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ca9ddfabdd..96247417f6 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -71,4 +71,5 @@ + diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -70,4 +70,5 @@ + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index be805adfa6..1d669e715e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -70,4 +70,5 @@ + From 46aa6ff7392d67ed351139380bffbbfb0b66cc53 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 10:32:42 -0600 Subject: [PATCH 66/94] use keyboard icon for key maps on home screen --- .../java/io/github/sds100/keymapper/home/HomeViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 45f3768f72..9b5212b89a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.home import android.os.Build import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BubbleChart -import androidx.compose.material.icons.outlined.Gamepad +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -116,7 +116,7 @@ class HomeViewModel( HomeNavBarItem( HomeDestination.KeyMaps, getString(R.string.home_nav_bar_key_maps), - icon = Icons.Outlined.Gamepad, + icon = Icons.Outlined.Keyboard, badge = null, ), ) From b39456b2f9034c71c5fe83e7208e365f66e1ffb4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 10:33:37 -0600 Subject: [PATCH 67/94] chore: bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 205a2cc2e7..03917b6ba2 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=3.0.0-beta.3 -VERSION_CODE=86 +VERSION_CODE=87 VERSION_NUM=0 \ No newline at end of file From 6f9218d5898b8ef69084206884702da604e24d0c Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 12:48:52 -0600 Subject: [PATCH 68/94] fix: say subgroup for add new group button --- .../sds100/keymapper/groups/GroupRow.kt | 31 +++++++++++++++++-- .../sds100/keymapper/home/KeyMapAppBar.kt | 2 ++ 2 files changed, 30 insertions(+), 3 deletions(-) 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 index afb5d01095..a6dd9107cb 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -49,6 +49,7 @@ fun GroupRow( onNewGroupClick: () -> Unit = {}, onGroupClick: (String) -> Unit = {}, enabled: Boolean = true, + isSubgroups: Boolean = false, ) { var viewAllState by rememberSaveable { mutableStateOf(false) } @@ -84,7 +85,11 @@ fun GroupRow( ) { NewGroupButton( onClick = onNewGroupClick, - text = stringResource(R.string.home_new_group_button), + text = if (isSubgroups) { + stringResource(R.string.home_new_subgroup_button) + } else { + stringResource(R.string.home_new_group_button) + }, icon = { Icon(imageVector = Icons.Rounded.Add, null) }, @@ -189,7 +194,7 @@ private fun ViewAllButton( LocalContentColor provides color, ) { Surface( - modifier = modifier.height(36.dp), + modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, color), @@ -216,7 +221,7 @@ private fun GroupButton( enabled: Boolean, ) { Surface( - modifier = modifier.height(36.dp), + modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), @@ -286,6 +291,26 @@ private fun PreviewMultipleItems() { Surface { GroupRow( groups = listOf( + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "2", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "3", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), GroupListItemModel( uid = "1", name = "Lockscreen", 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 index 09cb6bef3a..f551598686 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -342,6 +342,7 @@ private fun RootGroupAppBar( groups = state.subGroups, onNewGroupClick = onNewGroupClick, onGroupClick = onGroupClick, + isSubgroups = false, ) } } @@ -465,6 +466,7 @@ private fun ChildGroupAppBar( onNewGroupClick = onNewGroupClick, onGroupClick = onGroupClick, enabled = !isEditingGroupName, + isSubgroups = true, ) val scrollState = rememberScrollState() From 75e0a486a31b627455b087449c16b45b4b0036eb Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 13:46:59 -0600 Subject: [PATCH 69/94] fix various subtle UI bugs with groups --- .../keymapper/groups/GroupConstraintRow.kt | 21 +++++++++---- .../sds100/keymapper/groups/GroupRow.kt | 31 ++++++++++++------- .../sds100/keymapper/home/KeyMapAppBar.kt | 4 +-- 3 files changed, 36 insertions(+), 20 deletions(-) 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 index 9bf5d11e2a..b135d91c64 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -5,7 +5,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -120,7 +120,7 @@ private fun NewConstraintButton( LocalMinimumInteractiveComponentSize provides 16.dp, ) { Surface( - modifier = modifier.height(28.dp), + modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(0.2f)), @@ -161,12 +161,14 @@ private fun ConstraintButton( LocalMinimumInteractiveComponentSize provides 16.dp, ) { Surface( - modifier = modifier.height(28.dp), + modifier = modifier, shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), ) { Row( - modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .heightIn(min = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { icon() @@ -206,14 +208,16 @@ private fun ConstraintErrorButton( LocalMinimumInteractiveComponentSize provides 16.dp, ) { Surface( - modifier = modifier.height(28.dp), + modifier = modifier, shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), onClick = onClick, enabled = enabled, ) { Row( - modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .heightIn(min = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -294,6 +298,11 @@ private fun PreviewMultipleItems() { text = "Key Mapper is open", icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), ), + ComposeChipModel.Normal( + id = "2", + text = "Key Mapper is open", + icon = null, + ), ComposeChipModel.Error( id = "2", text = "Key Mapper not found", 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 index a6dd9107cb..5e241b1ab1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -9,7 +9,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -58,7 +58,6 @@ fun GroupRow( modifier .verticalScroll(rememberScrollState()) .animateContentSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), maxLines = if (viewAllState) { Int.MAX_VALUE @@ -152,7 +151,7 @@ private fun NewGroupButton( LocalContentColor provides color, ) { Surface( - modifier = modifier.height(36.dp), + modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, color = color), @@ -160,7 +159,7 @@ private fun NewGroupButton( enabled = enabled, ) { Row( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { icon() @@ -201,12 +200,18 @@ private fun ViewAllButton( color = Color.Transparent, enabled = enabled, ) { - AnimatedContent(text) { text -> - Text( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - text = text, - style = MaterialTheme.typography.titleSmall, - ) + Row( + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .heightIn(min = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent(text) { text -> + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + } } } } @@ -228,7 +233,9 @@ private fun GroupButton( enabled = enabled, ) { Row( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .heightIn(min = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { icon() @@ -299,7 +306,7 @@ private fun PreviewMultipleItems() { GroupListItemModel( uid = "2", name = "Lockscreen", - icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + icon = null, ), GroupListItemModel( uid = "3", 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 index f551598686..56153cc70a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -337,7 +337,7 @@ private fun RootGroupAppBar( Surface(color = appBarContainerColor) { GroupRow( modifier = Modifier - .padding(8.dp) + .padding(horizontal = 8.dp) .fillMaxWidth(), groups = state.subGroups, onNewGroupClick = onNewGroupClick, @@ -479,7 +479,7 @@ private fun ChildGroupAppBar( modifier = Modifier .horizontalScroll(scrollState) .fillMaxWidth() - .padding(8.dp), + .padding(horizontal = 8.dp), groups = parentGroups, onGroupClick = onGroupClick, ) From f9f5fb76d0dcba0c3459bf06f9e6ba0a529dca59 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 13:52:33 -0600 Subject: [PATCH 70/94] fix: show group edit name button if name is very long --- .../io/github/sds100/keymapper/home/KeyMapAppBar.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index 56153cc70a..a8a81ced48 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -599,7 +599,12 @@ private fun GroupNameRow( } AnimatedContent(modifier = modifier, targetState = isEditing) { isEditing -> - Row(Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.Top) { + Row( + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { val interactionSource = remember { MutableInteractionSource() } // Use a custom text field so the content padding can be customised. @@ -611,7 +616,7 @@ private fun GroupNameRow( if (isEditing) { Modifier.weight(1f) } else { - Modifier + Modifier.weight(1f, fill = false) }, ), value = value, @@ -667,6 +672,8 @@ private fun GroupNameRow( contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( top = 0.dp, bottom = 0.dp, + end = 4.dp, + start = 8.dp, ), ) } From e8a50ff9303bf5a72cdf00c1b9c5c05083f9cb78 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 14:15:24 -0600 Subject: [PATCH 71/94] fix various bugs with editing groups and show constraint mode in flow row --- .../keymapper/groups/DeleteGroupDialog.kt | 7 ++- .../keymapper/groups/GroupConstraintRow.kt | 24 +++++++-- .../keymapper/home/HomeKeyMapListScreen.kt | 1 - .../sds100/keymapper/home/KeyMapAppBar.kt | 50 +++++++++++-------- .../keymaps/CreateKeyMapShortcutViewModel.kt | 2 + .../mappings/keymaps/KeyMapAppBarState.kt | 3 +- .../mappings/keymaps/KeyMapListViewModel.kt | 34 +++++++------ 7 files changed, 80 insertions(+), 41 deletions(-) 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 index 11ba699cae..91468d5e26 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt @@ -20,7 +20,12 @@ fun DeleteGroupDialog( modifier = modifier, onDismissRequest = onDismissRequest, title = { - Text(stringResource(R.string.home_key_maps_delete_group_dialog_title, groupName)) + Text( + stringResource( + R.string.home_key_maps_delete_group_dialog_title, + groupName.take(50), + ), + ) }, text = { Text( 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 index b135d91c64..9b74af54b6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -36,6 +36,7 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.constraints.ConstraintMode import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.drawable import io.github.sds100.keymapper.util.ui.compose.ComposeChipModel @@ -45,6 +46,7 @@ import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo fun GroupConstraintRow( modifier: Modifier = Modifier, constraints: List, + mode: ConstraintMode, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, onFixConstraintClick: (Error) -> Unit = {}, @@ -54,6 +56,7 @@ fun GroupConstraintRow( modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { NewConstraintButton( onClick = onNewConstraintClick, @@ -61,7 +64,7 @@ fun GroupConstraintRow( enabled = enabled, ) - for (constraint in constraints) { + for ((index, constraint) in constraints.withIndex()) { when (constraint) { is ComposeChipModel.Normal -> CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { @@ -90,7 +93,6 @@ fun GroupConstraintRow( ) } }, - ) } @@ -105,6 +107,20 @@ fun GroupConstraintRow( ) } } + + if (index < constraints.lastIndex) { + when (mode) { + ConstraintMode.AND -> Text( + text = stringResource(R.string.constraint_mode_and), + style = MaterialTheme.typography.labelMedium, + ) + + ConstraintMode.OR -> Text( + text = stringResource(R.string.constraint_mode_or), + style = MaterialTheme.typography.labelMedium, + ) + } + } } } } @@ -256,7 +272,7 @@ private fun ConstraintErrorButton( private fun PreviewEmpty() { KeyMapperTheme { Surface { - GroupConstraintRow(constraints = emptyList()) + GroupConstraintRow(constraints = emptyList(), mode = ConstraintMode.AND) } } } @@ -274,6 +290,7 @@ private fun PreviewOneItem() { icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), ), ), + mode = ConstraintMode.OR, ) } } @@ -309,6 +326,7 @@ private fun PreviewMultipleItems() { error = Error.AppNotFound(Constants.PACKAGE_NAME), ), ), + mode = ConstraintMode.AND, ) } } 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 index 66f50f6623..1ceb1ba9c1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -195,7 +195,6 @@ fun HomeKeyMapListScreen( onSelectAllClick = viewModel::onSelectAllClick, onNewGroupClick = viewModel::onNewGroupClick, onRenameGroupClick = viewModel::onRenameGroupClick, - isEditingGroupName = viewModel.isEditingGroupName, onEditGroupNameClick = viewModel::onEditGroupNameClick, onGroupClick = viewModel::onGroupClick, onDeleteGroupClick = viewModel::onDeleteGroupClick, 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 index a8a81ced48..460e7fdcf7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -130,7 +130,6 @@ fun KeyMapAppBar( onNewGroupClick: () -> Unit = {}, onGroupClick: (String?) -> Unit = {}, onRenameGroupClick: suspend (String) -> Boolean = { true }, - isEditingGroupName: Boolean = false, onEditGroupNameClick: () -> Unit = {}, onDeleteGroupClick: () -> Unit = {}, onNewConstraintClick: () -> Unit = {}, @@ -154,15 +153,13 @@ fun KeyMapAppBar( onNewGroupClick = onNewGroupClick, onGroupClick = onGroupClick, actions = { - AnimatedVisibility(!isEditingGroupName) { - AppBarActions( - onHelpClick, - onSettingsClick, - onAboutClick, - onExportClick, - onImportClick, - ) - } + AppBarActions( + onHelpClick, + onSettingsClick, + onAboutClick, + onExportClick, + onImportClick, + ) }, ) @@ -186,9 +183,15 @@ fun KeyMapAppBar( error = null } - LaunchedEffect(isEditingGroupName) { - if (isEditingGroupName) { - newName = newName.copy(selection = TextRange(0, state.groupName.length)) + LaunchedEffect(state.isEditingGroupName) { + if (state.isEditingGroupName) { + if (state.isNewGroup) { + newName = TextFieldValue() + } else { + val endPosition = newName.text.length + + newName = newName.copy(selection = TextRange(endPosition)) + } } } @@ -202,7 +205,7 @@ fun KeyMapAppBar( ChildGroupAppBar( modifier = modifier, - groupName = if (isEditingGroupName) { + groupName = if (state.isEditingGroupName) { newName } else { TextFieldValue(state.groupName) @@ -223,7 +226,7 @@ fun KeyMapAppBar( onBackClick = onBackClick, onNewGroupClick = onNewGroupClick, onEditClick = onEditGroupNameClick, - isEditingGroupName = isEditingGroupName, + isEditingGroupName = state.isEditingGroupName, subGroups = state.subGroups, parentGroups = state.parentGroups, onGroupClick = onGroupClick, @@ -234,7 +237,7 @@ fun KeyMapAppBar( onConstraintModeChanged = onConstraintModeChanged, onFixConstraintClick = onFixConstraintClick, actions = { - AnimatedVisibility(!isEditingGroupName) { + AnimatedVisibility(!state.isEditingGroupName) { AppBarActions( onHelpClick, onSettingsClick, @@ -423,6 +426,7 @@ private fun ChildGroupAppBar( .padding(horizontal = 8.dp) .fillMaxWidth(), constraints = constraints, + mode = constraintMode, onFixConstraintClick = onFixConstraintClick, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, @@ -907,9 +911,11 @@ private fun KeyMapsChildGroupPreview() { constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, parentGroups = groupSampleList(), + isEditingGroupName = false, + isNewGroup = false, ) KeyMapperTheme { - KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state) } } @@ -923,9 +929,11 @@ private fun KeyMapsChildGroupDarkPreview() { constraints = emptyList(), constraintMode = ConstraintMode.AND, parentGroups = emptyList(), + isEditingGroupName = false, + isNewGroup = false, ) KeyMapperTheme(darkTheme = true) { - KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state, isEditingGroupName = false) + KeyMapAppBar(modifier = Modifier.fillMaxWidth(), state = state) } } @@ -939,6 +947,8 @@ private fun KeyMapsChildGroupEditingPreview() { constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, parentGroups = emptyList(), + isEditingGroupName = true, + isNewGroup = true, ) val focusRequester = FocusRequester() @@ -950,7 +960,6 @@ private fun KeyMapsChildGroupEditingPreview() { KeyMapperTheme { KeyMapAppBar( state = state, - isEditingGroupName = true, ) } } @@ -965,6 +974,8 @@ private fun KeyMapsChildGroupEditingDarkPreview() { constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, parentGroups = emptyList(), + isEditingGroupName = true, + isNewGroup = true, ) val focusRequester = FocusRequester() @@ -976,7 +987,6 @@ private fun KeyMapsChildGroupEditingDarkPreview() { KeyMapperTheme(darkTheme = true) { KeyMapAppBar( state = state, - isEditingGroupName = true, ) } } 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 33dddbb471..9f9e4b261f 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 @@ -143,6 +143,8 @@ class CreateKeyMapShortcutViewModel( constraints = emptyList(), constraintMode = ConstraintMode.AND, parentGroups = parentGroupListItems, + isEditingGroupName = false, + isNewGroup = false, ) } 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 index 8f16384238..c40507a95f 100644 --- 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 @@ -19,7 +19,8 @@ sealed class KeyMapAppBarState { val constraintMode: ConstraintMode, val subGroups: List, val parentGroups: List, - + val isEditingGroupName: Boolean, + val isNewGroup: Boolean, ) : KeyMapAppBarState() data class Selecting( 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 0657a7d7dd..6e22647620 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 @@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class KeyMapListViewModel( @@ -142,6 +143,8 @@ class KeyMapListViewModel( private val _importExportState = MutableStateFlow(ImportExportState.Idle) val importExportState: StateFlow = _importExportState.asStateFlow() + private val isEditingGroupName = MutableStateFlow(false) + private val warnings: Flow> = combine( showAlertsUseCase.isBatteryOptimised, showAlertsUseCase.accessibilityServiceState, @@ -198,7 +201,6 @@ class KeyMapListViewModel( * name yet. */ private var isNewGroup = false - var isEditingGroupName by mutableStateOf(false) init { val sortedKeyMapsFlow = combine( @@ -282,14 +284,9 @@ class KeyMapListViewModel( warnings, pauseKeyMaps.isPaused, listKeyMaps.constraintErrorSnapshot, - ) { keyMapGroup, warnings, isPaused, constraintErrorSnapshot -> - buildGroupAppBarState( - keyMapGroup, - warnings, - isPaused, - constraintErrorSnapshot, - ) - } + isEditingGroupName, + transform = ::buildGroupAppBarState, + ) @OptIn(ExperimentalCoroutinesApi::class) val appBarStateFlow: Flow = @@ -364,6 +361,7 @@ class KeyMapListViewModel( warnings: List, isPaused: Boolean, constraintErrorSnapshot: ConstraintErrorSnapshot, + isEditingGroupName: Boolean, ): KeyMapAppBarState { val subGroupListItems = keyMapGroup.subGroups.map { group -> buildGroupListItem(group) @@ -393,6 +391,8 @@ class KeyMapListViewModel( constraintMode = keyMapGroup.group.constraintState.mode, subGroups = subGroupListItems, parentGroups = parentGroupListItems, + isEditingGroupName = isEditingGroupName, + isNewGroup = isNewGroup, ) } } @@ -720,13 +720,13 @@ class KeyMapListViewModel( } state.value.appBarState is KeyMapAppBarState.ChildGroup -> { - if (isEditingGroupName) { + if (isEditingGroupName.value) { if (isNewGroup) { coroutineScope.launch { listKeyMaps.deleteGroup() } } else { - isEditingGroupName = false + isEditingGroupName.update { false } } } else { coroutineScope.launch { @@ -734,7 +734,8 @@ class KeyMapListViewModel( } } - isEditingGroupName = false + isNewGroup = false + isEditingGroupName.update { false } return true } @@ -747,17 +748,20 @@ class KeyMapListViewModel( suspend fun onRenameGroupClick(name: String): Boolean { return listKeyMaps.renameGroup(name).also { success -> if (success) { - isEditingGroupName = false + isNewGroup = false + isEditingGroupName.update { false } } } } fun onEditGroupNameClick() { - isEditingGroupName = true + isNewGroup = false + isEditingGroupName.update { true } } fun onGroupClick(uid: String?) { coroutineScope.launch { + isNewGroup = false listKeyMaps.openGroup(uid) } } @@ -773,7 +777,7 @@ class KeyMapListViewModel( multiSelectProvider.stopSelecting() listKeyMaps.newGroup() isNewGroup = true - isEditingGroupName = true + isEditingGroupName.update { true } } } From 7f0d1df69c7b84a0d9b4ca30a8b723ede897a2d5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 14:22:07 -0600 Subject: [PATCH 72/94] reduce padding in selection bottom sheet --- .../sds100/keymapper/groups/GroupRow.kt | 3 ++ .../keymapper/home/SelectionBottomSheet.kt | 49 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) 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 index 5e241b1ab1..1cae6fa27a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -66,7 +66,9 @@ fun GroupRow( }, overflow = FlowRowOverflow.expandOrCollapseIndicator( expandIndicator = { + // Some padding is required on the end to stop it overflowing the screen. ViewAllButton( + modifier = Modifier.padding(end = 16.dp), onClick = { viewAllState = true }, text = stringResource(R.string.home_new_view_all_groups_button), enabled = enabled, @@ -74,6 +76,7 @@ fun GroupRow( }, collapseIndicator = { ViewAllButton( + modifier = Modifier.padding(end = 16.dp), onClick = { viewAllState = false }, text = stringResource(R.string.home_new_hide_groups_button), enabled = enabled, 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 index ee90efc752..c146213915 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -70,7 +70,7 @@ fun SelectionBottomSheet( tonalElevation = BottomSheetDefaults.Elevation, color = BottomSheetDefaults.ContainerColor, ) { - Column(Modifier.padding(16.dp)) { + Column { Row( modifier = Modifier .height(intrinsicSize = IntrinsicSize.Min), @@ -80,6 +80,8 @@ fun SelectionBottomSheet( .weight(1f) .horizontalScroll(state = rememberScrollState()), ) { + Spacer(Modifier.width(16.dp)) + SelectionButton( text = stringResource(R.string.home_multi_select_duplicate), icon = Icons.Rounded.ContentCopy, @@ -100,17 +102,18 @@ fun SelectionBottomSheet( enabled = enabled, onClick = onExportClick, ) + + Spacer(Modifier.width(16.dp)) } VerticalDivider( - modifier = Modifier.padding( - vertical = 8.dp, - horizontal = 16.dp, - ), + modifier = Modifier.padding(vertical = 8.dp), ) KeyMapsEnabledSwitch( - modifier = Modifier.width(IntrinsicSize.Max), + modifier = Modifier + .padding(horizontal = 16.dp) + .width(IntrinsicSize.Max), state = selectedKeyMapsEnabled, enabled = enabled, onCheckedChange = onEnabledKeyMapsChange, @@ -124,14 +127,15 @@ fun SelectionBottomSheet( Spacer(modifier = Modifier.height(8.dp)) Text( - stringResource(R.string.home_move_to_group), + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.home_move_to_group), style = MaterialTheme.typography.labelLarge, ) - Spacer(modifier = Modifier.height(16.dp)) - GroupRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .fillMaxWidth(), groups = groups, onNewGroupClick = onNewGroupClick, onGroupClick = onMoveToGroupClick, @@ -254,6 +258,31 @@ private fun PreviewGroups() { name = "Key Mapper", icon = null, ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), ), selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL, onDuplicateClick = {}, From 540bf6b95e08ff2be83a54575ba14751f3b181cb Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 14:48:11 -0600 Subject: [PATCH 73/94] feat: order groups by when they most recently opened --- .../17.json | 408 ++++++++++++++++++ .../sds100/keymapper/backup/BackupManager.kt | 10 +- .../sds100/keymapper/data/db/AppDatabase.kt | 5 +- .../sds100/keymapper/data/db/dao/GroupDao.kt | 4 + .../keymapper/data/entities/GroupEntity.kt | 9 +- .../fingerprintmaps/AutoMigration16To17.kt | 5 + .../data/repositories/GroupRepository.kt | 7 + .../github/sds100/keymapper/groups/Group.kt | 3 + .../mappings/keymaps/ListKeyMapsUseCase.kt | 12 +- 9 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json new file mode 100644 index 0000000000..8946e01ad8 --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/17.json @@ -0,0 +1,408 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "88c90aad1691900805c9d1f229f42d71", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, `last_opened_date` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_groups_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_groups_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '88c90aad1691900805c9d1f229f42d71')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt index df56974e0d..e3d9bfdfd8 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 @@ -462,17 +462,21 @@ class BackupManagerImpl( .toSet() .also { groupUids.addAll(it) } + val currentTime = System.currentTimeMillis() + for (group in backupContent.groups) { - var movedGroup = group + // Set the last opened date to now so that the imported group + // shows as the most recent. + var modifiedGroup = group.copy(lastOpenedDate = currentTime) // If the group's parent wasn't backed up or doesn't exist // then set it the parent to the root group if (!groupUids.contains(group.parentUid)) { - movedGroup = movedGroup.copy(parentUid = null) + modifiedGroup = modifiedGroup.copy(parentUid = null) } RepositoryUtils.saveUniqueName( - movedGroup, + modifiedGroup, saveBlock = { groupRepository.insert(it) }, renameBlock = { entity, suffix -> entity.copy(name = "${entity.name} $suffix") 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 52d3b3b711..9b2a68826f 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 @@ -38,6 +38,7 @@ import io.github.sds100.keymapper.data.migration.Migration5To6 import io.github.sds100.keymapper.data.migration.Migration6To7 import io.github.sds100.keymapper.data.migration.Migration8To9 import io.github.sds100.keymapper.data.migration.Migration9To10 +import io.github.sds100.keymapper.data.migration.fingerprintmaps.AutoMigration16To17 /** * Created by sds100 on 24/01/2020. @@ -51,6 +52,8 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 AutoMigration(from = 14, to = 15, spec = AutoMigration14To15::class), // This deletes the folder name column from key maps AutoMigration(from = 15, to = 16, spec = AutoMigration15To16::class), + // This adds last opened timestamp to groups + AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), ], ) @TypeConverters( @@ -62,7 +65,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 16 + const val DATABASE_VERSION = 17 val MIGRATION_1_2 = object : Migration(1, 2) { 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 index 5401f87836..b5f0c15eb6 100644 --- 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 @@ -20,6 +20,7 @@ interface GroupDao { const val KEY_CONSTRAINTS = "constraints" const val KEY_CONSTRAINT_MODE = "constraint_mode" const val KEY_PARENT_UID = "parent_uid" + const val KEY_LAST_OPENED_DATE = "last_opened_date" } @Query("SELECT * FROM $TABLE_NAME") @@ -54,4 +55,7 @@ interface GroupDao { @Query("DELETE FROM $TABLE_NAME WHERE $KEY_UID IN (:uid)") suspend fun deleteByUid(vararg uid: String) + + @Query("UPDATE $TABLE_NAME SET $KEY_LAST_OPENED_DATE = (:timestamp) WHERE $KEY_UID IS (:groupUid)") + suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt index ae13d34187..f1d9823476 100644 --- 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 @@ -8,6 +8,7 @@ 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.byNullableLong import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer @@ -51,6 +52,10 @@ data class GroupEntity( @SerializedName(NAME_PARENT_UID) val parentUid: String?, + @ColumnInfo(name = GroupDao.KEY_LAST_OPENED_DATE) + @SerializedName(NAME_LAST_OPENED_DATE) + val lastOpenedDate: Long?, + ) : Parcelable { companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. @@ -59,6 +64,7 @@ data class GroupEntity( const val NAME_CONSTRAINTS = "constraints" const val NAME_CONSTRAINT_MODE = "constraint_mode" const val NAME_PARENT_UID = "parent_uid" + const val NAME_LAST_OPENED_DATE = "last_opened_date" val DESERIALIZER = jsonDeserializer { val uid by it.json.byString(NAME_UID) @@ -69,8 +75,9 @@ data class GroupEntity( val constraintMode by it.json.byInt(NAME_CONSTRAINT_MODE) val parentUid by it.json.byNullableString(NAME_PARENT_UID) + val lastOpenedDate by it.json.byNullableLong(NAME_LAST_OPENED_DATE) - GroupEntity(uid, name, constraintList, constraintMode, parentUid) + GroupEntity(uid, name, constraintList, constraintMode, parentUid, lastOpenedDate) } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt new file mode 100644 index 0000000000..4ff7d3a310 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration.fingerprintmaps + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration16To17 : 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 index 040095a000..ce6befe960 100644 --- 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 @@ -27,6 +27,7 @@ interface GroupRepository { suspend fun insert(groupEntity: GroupEntity) suspend fun update(groupEntity: GroupEntity) fun delete(uid: String) + suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) } class RoomGroupRepository( @@ -81,4 +82,10 @@ class RoomGroupRepository( } } } + + override suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) { + withContext(dispatchers.io()) { + dao.setLastOpenedDate(groupUid, timestamp) + } + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt b/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt index 496b62771b..6633399388 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/Group.kt @@ -10,6 +10,7 @@ data class Group( val name: String, val constraintState: ConstraintState, val parentUid: String?, + val lastOpenedDate: Long, ) object GroupEntityMapper { @@ -24,6 +25,7 @@ object GroupEntityMapper { name = entity.name, constraintState = ConstraintState(constraintList, constraintMode), parentUid = entity.parentUid, + lastOpenedDate = entity.lastOpenedDate ?: System.currentTimeMillis(), ) } @@ -36,6 +38,7 @@ object GroupEntityMapper { }, constraintMode = ConstraintModeEntityMapper.toEntity(group.constraintState.mode), parentUid = group.parentUid, + lastOpenedDate = group.lastOpenedDate, ) } } 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 999fc37be2..d0012d5402 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 @@ -55,7 +55,9 @@ class ListKeyMapsUseCaseImpl( private val group: Flow = groupUid.flatMapLatest { groupUid -> if (groupUid == null) { groupRepository.getGroupsByParent(null).map { subGroupEntities -> - val subGroups = subGroupEntities.map(GroupEntityMapper::fromEntity) + val subGroups = subGroupEntities + .map(GroupEntityMapper::fromEntity) + .sortedByDescending { it.lastOpenedDate } GroupWithSubGroups(group = null, subGroups = subGroups) } } else { @@ -105,7 +107,11 @@ class ListKeyMapsUseCaseImpl( override suspend fun newGroup() { val defaultName = resourceProvider.getString(R.string.default_group_name) - val group = GroupEntity(parentUid = groupUid.value, name = defaultName) + val group = GroupEntity( + parentUid = groupUid.value, + name = defaultName, + lastOpenedDate = System.currentTimeMillis(), + ) ensureUniqueName(group) { groupRepository.insert(it) @@ -177,6 +183,8 @@ class ListKeyMapsUseCaseImpl( list.plus(group.uid) } } + + groupRepository.setLastOpenedDate(group.uid, System.currentTimeMillis()) } } From 3c7cc6254000b4a9f7f3ff6194296e40e9639796 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 14:49:28 -0600 Subject: [PATCH 74/94] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 03917b6ba2..44da105566 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=3.0.0-beta.3 -VERSION_CODE=87 +VERSION_CODE=88 VERSION_NUM=0 \ No newline at end of file From 0759b22eca01de3a3aedda00230a1736ec2c756e Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 14:50:05 -0600 Subject: [PATCH 75/94] update changelog and whats new --- CHANGELOG.md | 1 + app/src/main/assets/whats-new.txt | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c66086b0..0add2838a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ _See the changes from previous 3.0 Beta releases as well._ #### TO BE RELEASED ## Added +- #320 🗂️ Key map groups! You can now sort key maps into groups and share constraints across all the key maps in the group. - #1586 🎨 Customise floating button border and background opacity. - #1276 Use key event scan code as fallback if the key code is unrecognized. diff --git a/app/src/main/assets/whats-new.txt b/app/src/main/assets/whats-new.txt index 7450bdcdbf..35aa74b350 100644 --- a/app/src/main/assets/whats-new.txt +++ b/app/src/main/assets/whats-new.txt @@ -1,9 +1,11 @@ Key Mapper 3.0 is here! 🎉 🫧 This release introduces Floating Buttons: you can create custom on-screen buttons to trigger key maps. + +🗂️ Grouping key maps into folders with shared constraints. + 🔦 You can now change the flashlight brightness. Tip: use the constraint for when the flashlight is showing to remap your volume buttons to change the brightness. -❤️ There are also tonnes of improvements to make your key mapping experience more enjoyable. -👀 Grouping key maps into folders with shared constraints are a work in progress. This will be free for everyone! +❤️ There are also tonnes of improvements to make your key mapping experience more enjoyable. See all the changes at http://changelog.keymapper.club. \ No newline at end of file From 3176cf8036329323c6d6df53860ea0c3e7137642 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 15:07:02 -0600 Subject: [PATCH 76/94] docs: update assistant trigger documentation --- docs/images/advanced-triggers-paywall.png | Bin 135024 -> 174007 bytes docs/index.md | 5 ---- docs/user-guide/keymaps.md | 35 +++++++++++----------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/docs/images/advanced-triggers-paywall.png b/docs/images/advanced-triggers-paywall.png index d12a5f897e0a06e7d678d18f1fdf975dac7b3ff6..dcd1490bad4c9b932500d6db69609b56a541f3b3 100644 GIT binary patch literal 174007 zcmeEuXH-+&`z00>MFBAm+7RGNZ-(mT>ifPnNG5vidFp|^+- zF!Ua3n8W*<|E!r0^L5s&nfESS+{DW*=RRdW``PDwe5tN@`zFmz0s?~DN-v&k5)fP! zBOth8N^}`~^T&tmJpsX`$Ci3ZR%&Vl9N;S=0>VoK1XsY`rHcbEUHZ@d4O6hsPjK-^ zRN$X-xGllei(|k)vrBjX$3FN-K=9Pb!p+&%;^EVm0#A7ag!shygn9V*1PKTTKVSLh z4EMl6d9h#q*}uv!C`OR?@SlAa@O}N~JOAuo2m7M77^lpBg8KP_N>Ir&8#de_{0SWxcR`beBinMmuLGQ z&vS|7e|}DI<^Sz7$dGGEg8#ncuBj+TP(DD11s|@s%PDCS5fM$#Yy2i4cu1i1{HeB2 z`r53m9_`V)%@YI#Gb0(%&(?{FHIwhx9GbnRTN;`cJS0@SW7XXRQXDb_k-nLN(<%PT zDJL`HRT;~r?i&`d>H~RkDIT#Vs2&f$%IWokXva@cwa-UlgKN_HS5KP1>ocfbGSL`mVIaeZQv0 za>)#mn68CwpO!hLm@du`zBF=j^0>aG=T+i8b~WYtRU-WS+w{(cA@0jG_V%k34Y6%) z@4V-Z_WPD`n?a97MV-HJhSQK~6A*lFS;C!2)5nb3WsGaKnZ7JUaSuK2coEB-g!J59 z`rY0A0flvGnw81orMeX+$5^HV8()o5$n7vwQc)S~?d|R9;ZKVbqUn!y(&VFBTU)cS zw|_uQ9W4=zyzkOr)Z0$Z8f|H5sjE}Y$-~pYdp-zmC@u95nRdmWI~P}%#_S$VO~XG$ zO~PQJh57j%t)~7b340!Cqjth6sw#E9?#ao?e>XQ*H#cb?JaBDdeU@!{2x~KCqe?$G zDljVFP7`%Lz~L;dtnO1#EG;c@zq--6xM*Q-k4)f93T!R*FRS!ERvt?FUSZ3o^sGO}W7U%ygaZH(rgCv~k-c7rj#s&?qHd7ZHo4-r_ zZ9P3bSS&U#FR!|~`p1vgky4X{!EI@x*TE)5ufcXtAgv#Ztvx+`f0P-^VYdOadGtF<_-Zf;sOGintwRh z+11q5qZc|S)^n41jKMn~3Y8-^`8X&#Iun2&&R_cTC-x|;GgLo7S=_ta)y2gW{6zcy zSPdQ>8R^jUSlIWI&g`M8dkPyJ@cPAbXop(eKiKn_UiU(vohzA_mnoMP7yEQ5C~sYh zVUQIO64Hhs9A_K%Rg5jX8MrsU-X*$9LpxYoZFEo0!qPHmMJp@QaiF!eHI6kVCPtDe za~!*uCgNC9GL<*Zy9nVI6Z84=lRw;?>yK}lj-hUyXM0!_e`1lBw|9oL|M8E|-RW9K zUS7Cf+jTku0u&1MIId@%#KXhrcIbOyVb3b#N0M~W1qGd53}{wa@ampD3kE+uJ&Ccg z!x0h6ksWT%wZ0Y>i#wRw%*>5qcDQ<>hQ?qY5{c~Yexar&oECTY?%iqwM?R{(lQa6k z#lyqHt*x!^-@hN$qvk6ht!t!LFJmzG+1c4$C#ecizKh*mUMDt2V-z<-%Z-~>T*T=8 z*IXl6WDKcZ8)^0ijQ<)LNlr@g9)HYvb`}^;$`Ik2QCvK>oOS-AZ<#WnW^&Z??C%g0 z6rtg}bb3^flOx>ZC4Emg3eq|w!uN~U`JmBr)qG|R^8z>Ho`pb9S3L1L`P&wJy|Jlj zF9T;ao*@$zP&Jf+<)u$^>=S&@7*LJB~6Nt2mb5pc!Pid`NFSJtq>(nM}k0ax`>_P z-vZzFeY)f!F_ zeM@;@#64HXDDRi`^!5T5a8n+#5t7IlFy&)rVadcEAd|L zPGSrreVK6$`83&d$bN?at25 zc$MBJffUu%apw;{Zf&}0EiKa5Nugx7t}$hLOJwBc=5}U$-c-Z{4-Kma2=Jd5=kjXLr+glLld9X z3~do)VybHNL(OF6XU~r;<{3ik?Z-Fr@_Z<6Um@U&3n+t++TFg^-r}rYHe|!WIevpN zHI27^mi)m3-OvxESHPh28riG>mxZ{T9Atp2Ub%7w*fSJ?C@bq&PAe&&v#7Gq7oO!?XDg4%SH$oij{k8N35AZN@Jr0@Min*a_U>1!^7VQ zSvikVpO(M)cJK0~^Kb_GiHdBy>Du+}vY9(LFLvNi-u?7$ER|xdSUQS;8fv1e(GMg! zG?J2Zb#!%UqYCEdvq1oFnB92y4u5dKIdA#6&2($AXJLnM>tJ0nvHGg&?L^4Sl=O5d z-yNS%#pCl-_BLY0QZp?l^TWdem`@IM(;#L@NJ%k=)cd+`{pld~^fYXzbas&PpO=f1 zYBBFB-`bl2@8hEqxT}eZH@T+?iOj`yb<11xF_$kP$Z{Hv0W^teOsnHMlnp#@0l9I@XvFe(d>bkm(na?u$|DTYROUS-Na=hanbAc*>XKpo zc)U!5qpG_4#z02XV!lq90kD<1pmSr!=Z1!cJ}+N!1eTe!6jWDpb!yZFa93So=<3Qq z9FOuQ#GCO^#lmg58zqAl;Otq&i3{VF7p9eS_@IMEl|X{ ziBoL)YZ0f?#GqbDNy*gIfvBiiyMvU(M49OX8)ao>$MwYCUQf3x7!alB<}z8P0M?mA zqc^=YR`!oUm;@nYcl{x!@TkG7S1D;}g`;*4NV>j1L3AzXPjvdU5F2gDw7z<-Jc&3@ z@hKB;{iI-SSK=9s$jo%ITvAK3`;lbaH@b9qST9xS=lSYTv$t*2W#Y=a@9Wq~L2Gql z##`4GfC1BoJnlzw73Jm46icKeC7rKTWu@d+dpkI&io2H?8ycCI@VjS%yq_WI%}xre zh#lqT{I|C~Bt){#PKZVg3T?}ck-?wtLQ2bK1>9@&^(TAx9ME1$vFaHT*+Q<9zzsUa z4G9TMA1lRPyLOzG#%l!PzL1cZFh(%tQ%?`b>}*PAWC(++L zw*Sb(MU~@*VkaVpCIT_MhBvRu(oqt@o=jW~V8Eqcbxy9(aln5K!29cRuC1*#YMGiF_h#xueEcXsI=}5B z1ybllvNR{Bq^Kw)cjzV~h=~bq2>=*5PS*lB!xot?Gy7}ERSlm%wz~iNb=sn`nAb@+ zu-nLE26>RK6crVv={4luXMg?r-3qBUA?*;Q5SNt1d!Ch{zC1CJ>=qNyy6XGqW1z}Z zqA)xA(MQ&U4vfxOgc5r!bEs&=Wb;v7gccaG;*m$I>+4^bl~N$Z)mAkHJ?opmp%+;r zBj`UUL%w0g65a&9p`SJ1brKP2lRwx@Gkkv$;Np3P06f^@&%Cw-v zuIB42ub@zX6wb`dERUa@O<-2ih1Vicel0T6vm)-9+uN@B66Q~z&iA!45fFTcEG{nI z*eC~)8>EfCzO~`u;rH(^N3zIoD0eUKVip%clj}tSv0|a5y#fM&QTbeg05T7few-Igmbr zL0TTD1j+5@qp-Mje*vCg+w$c2M+nO>|{KkL-6X9NYQslV^;ec-lPPA^L?FE1@+N_d(9zqApuw6>SWj+d!$xyczqS@UgW>(tqD^ySjd;Hjl zoV=WBy!m%6g+L*S+GJDKNLROzOw-ZP*ucQ=C%gh+^9dgjR#siSFE=;0y_1YA$TM{@ z%CYy*#9~SIMjMHO($bD!W;tzzXkfzJ;*$d6Sb7GAtaJ}vpAXh)+~$LsE#V{&jQS3q z*H{nE)$e6_k!mVyp`=%aIDwB!daqVd-Rg{@g|$PBPe-lf*0e`N4?S>gn%xbAUku+H`WS16zH%vu zrY42!6J%p!Ic{$TTeG${DS0;O>1876UhtHjYp3lalHhH=poo=sLuV)%2v!XZzSn}= zFmC$?2cM#%sBhhnm$$E~s&aR81GTK$<2dHoz+`tYJm{xVcgcZy>HD(9+?fLaGaIu0*WGF!DycFQem=7TpwNQr5flw#urp2~af7jPS{ScgS{(W9K}xAOCA%${HT z`Sa(M;jAkJrb@moCP&{U?9}3Tsh#KO96-g)z%Y*WFenpYVd-!XLAFzd$qmnru^Jfg zbcz-&E-g_|Cb5wk^Ni=KPy%qbIxfkA)oF-0)qMK2Es9n&$3HA_#;L5jTSwjfvg}q%0EOMWE{r zNyhB#SJTzI_E$3^jba?ur~cIMDK^L21>9f^kjc_akByC$sLBp#E*DWzR(5xHZ)$8@ z^J+qGR_SpO5{CNv9vPI4In`xJ`x{94B(z#^iWSXJ{}ycCEidwr$S{E?1v@-XH?Bxb z1R1Zw)!g%W-5v<^3k%*7Tm$|6yu7>`>a~Ywt+b+kyb!0jEN90Si>k?-g9n!go~$t| z#nNI|_Wh1ndlpjm{95E*wEeE`TDWobR4y6hGqW`x-0}X={-D3Ej=jOFq13J06mr$o zMycr@it#XqNlk{Ep`Z-^;rxO#%Fd|VmGKX?HZwajCBLnZ0p>D4*%lZT;In4>!9zA% zC(g!5aH%pK))1m=&y<~OBP*>3nLsQf-gu_^rpj_{a<}_WoposbJ;vwut1P~s$r+y; z&nBh>_jYaD39_^Mf@1cLMz3^Wk0KHvX=m%5>)jEzIp)KW#wTORnVCA#$%H})D6EI1 z*IF#v#=)Vqsfm*+o#a~KV5anfTZ7joR8PD~Db@qid*8BcYKl8ebes)dZem<*rMBG` z%+O8P8x>_85i$15nxDXwe=$Hn`oYdo02G0?5i;On2Xz1x0fCQ$>gkh09N7!%ao|rM$-5Szcc-V}z(?3yx*LoZdZm+rpSNp8aLLLBog6$+jDd26Z23rmq&6>_ zLO_trTQ%t*kTSFJww-^;twBIg5QHOe`gHYhldR8K5K|$VKpFJ@>VuY+7C{)4-PnJB zfA;U+zhrW%>Yd_6w{G2H^E?9pMql3-M26e6BE3)LiH1f!rKxU>jXCvncLP>u=3Bl@ z^G1q*g^ACzr1}^CY{{7Fuhia+|h~^Fz#cP2Ca-@*gW}58l41KdW z7lQU(9w#QY1<`=#>;YnT0G>d>#L1LT#0aP*_+mt3BloK+Ost9H)Os$kg%GmGT7M4K zr!kzJ<>jl2G4wa!;$8qt0!J`zqK|OC!kwU3`4mJI+FREeNdAHwoDmEsrI*D`a8*|5 zarxt9j**a7cJK7znHeKZO%4W=xHxYb`j`u@^oA&bqedkbfor$wL?aDq&@DGckdV zOeSvF2{sRp1DwCeH#t4cgqypU-PDBK#K|5lq9S>zwbJg>YI{f+J2|z^G>k>jN&~_# zIzv<{V7V_X6S}-62=c`CpU^K~zRb+bfLv;hIDaEVgY4}c%#h>^aS>aiAIu8~2_?On zl1_JKpZ@u?X^jC!ZfjDZ<~qSc1zTHNUteF6uH>!-kYQH#_B>t<(ZEJI&Wn7dG65*# z)a8-PP>)hbOHJh%56;T9=e_vP5bkfC&65t%e{YGB5!D+ls248mVZ*lkeL;QSCFr}L z5Tn4f=yo4-aPa$g*>EzziydgYPoGm$Q{ydMB{E35CVqz4o3!8WTXYcvtP9x>yBY(o zZ3l;!`V9k446jogI$O0ZF^%%A`w^-Dt@b3jd`nH8n&i;wz}N|vs3h@_+@8HBS8D7J z8`gbDCg!!KL&wbvFD)+@p>d9?|Krtk{n|0`x9;HbQf5w(A|l3{H*YRJ>)OQO_B;m1 zuqF@5>H*f5%1E?QQEG5cm4u$^T_7Jb)34~|U1xObGXN~}=I7_}_|}xsI*a)QmZLLh z!*9`M9&;a!JM+v+nmZrW>temh2!7K8$6@0zkd)b2S!vvG`J@ICd~~#hl@-k`1q6E8 zP3=>3biGmeOSa^=I71T?Mp73k#c>ltLfB%~h`dQGAcN(#by8AO?HnDcX{#hNqCw%u zIp{p2$AY^u2F&4SS?MDL7^aY4M2z<-lYZvUE7p%_+bb*g{{HG^_Cs1i0^ouzK=e6YYQqRdRq7Hb!xLO85vFP%Nz2owm!=IRGeoTgch?{uwULMzBci-~9A;ujTHEcsB$YnU)i|H&qTGXzlQOhsI* zFkQ+F2~Rm5VSA_63cJyjq6Y#-Ow)ydI9Dk2 zsh8J5-zebuI8n;?JGU0TArP`F*|HK6Iq_A6`Lp>NP6PxS9Kyn{TfOI|r`PxW{QUi$ z=bDShS1-TA0+bSxH@f2HzANf50b=08q-r)}e@}1kp0ecx70t+wyZhnJt))7veNRcF z6c!rXqe!~+e59*E^iz%<8Fm|5*zWfHZf9>VC|DIjMo35qm{6}K>E_@CAAYJ^`uh6d zGtamW1OoB$QU~S9Wdm-N*by|^dw0o{?Oyfdw_%i{_d26pE8rl=HP-xB%S0FNa4bKr zbI`ZgUEKn;02?PKgqod&>J~@)3qaYGl(1d-6Kc3PYBh3O3|JVz`*+4D?Dto4w%pQY zW~6+7%vW5${LV)>Nb{o@+W%z&DB(R5P@dogD3|fv5lZ9x&7M zxX(Mgx#J(N5pdTFlZ8D0+oNG859SPB>5-);WQn zv%9lX$o~HRzP@3bYIAe*j0{k7sJ0hAr=p>0gtnm8Gk;#24|$9b1U zX8%)BxwXkZ`}QIJ5ygmYiG-(zkrv55NcJ14trtZ-Dqp34N8ZZSRf+4FOZf;MK(~hH z@R=6Y>aft~CoX+_pip$DC^(8BzN3#1V7x)oYH4|ySAe;%%?deD zWxbjG?D}%DHK@DX$1NLSDeRUVL=5x%?Cj#5soIeZQ=j+*1l$hRs2QJ| zA!Ms#jxQzd=vY`=%i?C10l9DGu6bCQGi2knF>`h>?c5`s6!Ym8vgV342ba-(ewzgl zL&nRde~pbjx_~;ME{ukP`gTjIEthPSqUVUS7AGk^C_&U9ckrKM6#Ozuj~ z@8HIUYc68(?SXIgG#!fh6g@e(j&fk zlbpQY1a9r(@_Vs|{PpY6$EdQ-y*l02VVa^3_ZhT9$UvDHBa2`B!oy1DKA2z~53Wrbv8XO9Y;ls9qg1vO!h z^jk{GqyajJ03+j@_k$FxUsTO?W&8rEy0mEw>|wCnwnzL-X<$ zK@ck11%dD$C8dVO^$c~zpC4X;+XU^XYUo)QVZ3)*{}O=Co)RsyPP$BYfx5OQNhQ=1PT`gk0%a%RQC3DYZH~6(bAnwmyU}$LFKgCGSI@JXjGeQEdUA`34 zB`6>;JUcr&6{AjZ$l&28D=O5l?(P3ZLe_{s)T}sxEem699r*2vrP&5K503?`mO(ZbeIyhXkWQB$ENA19S78Dd@e*wzZq1oB|Pl2cH z3{bQwpOvL$Phfzsa7J904iA?RiztQhFn3C_izjd)4-YUFa@n7CU}~yDg?V|Y{U2}K zxK^e^drSS~>>UVrNDQd-V($%O_o%5;Fc=I>r>wQ>O1Or4<3-ig5Z%}lf0!~D;Kd*K zh*w^}R}^vdCxG&z<6CRs=w4J@94UPdka}Wb$!Td#ziIqH%!+5WH8Yd5jlMg&nAZe@ zA%O*-$wYQ&zI+KYD!D^H^@N1dI*c6cgi;%{ zBFG;7r_3fcJpZ<9N_d}updvu}bSrZ!9P~=FU;YIi1I$QF?6=D&?)?@7s1jepbO8By z@7_HC7{LaHxv(Grg?jgr4WOP|VIe=CyBioZ_JiJDHwXfvG*LL%l}S)nJdpJ2AqS20 z5=~`bURtQLIii_oq&lO#;F7T~Vn=2IvTJHsL`Cabp(oPzS3&SSIpaw}u4jVZZKizp zZel$oT1=dUXt)#O0pnR8vqHpwJ_P~6L4?6WXOOnA!sg2OP@iV$K5(a zEgOi#lo=wq5vq`-r;&=u6zk8xVI8kD~U#5Jgo9Fwa zm3>EJ(+JSN&Lhj2-@}4?0{n{%xEZ+VH|}{fU)Ornf8w?XZb1sbZw9DG zQFeCQ!uIoKZ4(pxPxBZ!1c7gLnE2KyYI(Hd)7%yPKpOhzXZPP1hr1FPUb{=FUG#ya zHv237h}U$ld`VVJIST*YGVT(odNDL1p(-?sD1 zV?N-peJQdZ$SSC*sa=C|vk=FT@7`VVJq|jpJEwm&`YzX-McnK3kW<#51!Ph%kjUY$ENegMO#c-GJfcFFlz0)%^eztEJay(Dk*x0y;UC#6` z_qromg_CM7tj_ZY2#}JM1#J`0w$-*^CNiRQ2~UncJ)r7WWC+}&cfO&x88I}A+Kto= zDwmo8C@)T94cXK4g`shFqbe%O`D~~EiKzjRYstL!_Eb%i*LtJubmOgUHGMD8OM>n# zBqhXaR{QN2W*%t+#h9GjdonX0pTonC3)e~L;~|bf71PICpJZ+N-|gz}z8`En&!;Y_(KN>?XHoV!`+v~jv2b}7Kf`57zSk>1in{*3X#%FP^d*uYN%q<5~_R1+e z3o)_>Db}*&CZ@6J>CN7Q^f2N5e1_8}(5160o8#l-NJ_^Kgy?t-rmQXq>(zw80_9EM zDlJ+dWkcJ$uebNOuTVBFDGj#}bkwtcgr2H+^9)b|($jL_{%nXiaB>gHDyG-pJ$VQ; zE&Tla2SC8~HPE4Nb({I|;{qVNg9cXoCd8C^iqVGuv7+)z#Hia&_gh2L%Uc1!`$&fl&g`>RRxd8=|^IKv_h`fb)}5 z$Q?4P1ahE$dsq1QDxLRnARc7Gy}iArF|pLlOnO?{UB8xVxr{yO02n5kv*i01=jJ}M z-HFf0XaQGyb#9&}>S$8uWNc`dMrP(QGUwG$)>tC3a+9*5t3%5BczAugDYG|SeHon9 z!$qPeJsvcblNLqLSMcSB3Vq|>e?Kud&y@mduh!EaNjdma&;c%6@ax)MU=1N_GySXm zti3Ahc!Um?z}$oNP_JhN;;_=WoHk$OGxgWijOznFJL_Y&V^)YRl^ z^4joQ8|vaUX*@hmgCjNYlMLs!IyyQ=6!+wA&CyAnZg)jRMJW%;UJK5)+Ffe#$CaDh zpKMp+Vj$oC1H@S~BpmyOE27R*%-8QTK1VgUyI%(_OM_{Hmt7=OUb&bwssxp|Asj6XMnmT^D^z+@)o=R8yI3hvKcP0a+Z>+81A zd0WzZt)#pp^@#R>GU$kMu9%(ixv&0YUF&ukAnZs<=)~L)s*M(25PeBX!tH5kjE{~f zoV^7si6RJg{s#_+)A!3vjDa@rc zXlWjbI+Xd$0zds=WmfM@+(uYf7&)st$sC<_rmC`f39Q)PVskE~Zq)jufa`Zv*P;H> z{`u38f`Z4Eew%YG?jC(-f0<$~$oGqfD9jAdJUTudg`6+Dj@OgrZ=2~8iMI#O~hAVgD~vI%kr}k+qxL=r_d(x317!4>65alW3TkuP?w+m@=c$z9=K2Cs++RjK>-kTbO z$jHc0vR-bUtU*w7OH99qpROfr#hVGyfX;M+G%b-TVy7xgI0NCyFl56Anwlw5@I}^| z)TYfb?>S%A(84Vx>i=6FoaVF6tiE+A716&nEj*06u=wlGG|IB2pmL-awgr zn}tIKh&9({Dw%+ohUgX9Gury9C~J5%(EWHytS`%)%+wwq13Yx0I)tsmTkw8|7j?#8Kz5=PEaaijmA?vvL1i-JY!d z{e8f#61{r$DjQ{sL4VXLqPK>m+b>8km{y!L%IkED|I=gJCo{%OYM_&1bH2ex0nF7ad4smHS==XjVHQc_kdoJL*-$b0}Q<{9XUYH^73zI~vaQ54sMV_yo0|{Cdx(NtDr2UnbOCgLQpP8`$ zQRvV^=XW6!P1Z3p2kP)~IHV3QiH7pGf_y|mfBdq6cjpIXYrW&N$dRZRppt;JYETyE zdz7z)TMVsGAc;ImPEI1GsLteDI4y2;@mVp7??iXMx*a(}C*fPNBj_$Ttl zjk=9bbYecgFmCe3J-dU_h|Q0IZuDceH5f}w7W4qs3j;mj-scYpJOUm(` zn^0rl{l#MPd8`e;7|x6^_qnBQ;64nJF=-V@^6c5OS!wBuZuZ!L<1~|1cS^pLUmJ0w zQF&O{HOowH!`kP475Pp`TIW{A7=u|LUz40-ySaOo{2>FqDeLQh7~l7)FQ?+9MmS9* zOWa){QwAOI^alMmAe_&f4u9_Hxt~fc>R0EC#<#6A3ySeQw6f))&dtuw9vd?h{slCm zHJFZ_Zmnqx2}@(mZB!Se$8$qI3fFt+4ZD-t(Pm&KTM$c5B@<`TnZ0M)wQ|GzVF}g#)r4ELq>d8R+$j@fb1IpHkqLLX0k_A zw>(V{CdRL8KKI`S@rIKY#2b4nYkhaE-hfat;WYa_{-7Sp=C601L%=7|!Db8olux~r zE6OJcTY~XstpIs81z;kKUOypv1wjZ0bjR=PxaD^ad?aUmkC?6f20vG?!R{xlT9u0n z3hsKh9=|?B_FM{48c@EcvpBZmCsQcXs2kcII>S=wYQgQ(}>b|a+sk4L@YW> zWmA+%yb$PehW8I=jTMhZwb9WX$l-0-pDN7<^&fl-(ZSO6``QKLtM2Y@7(`K&@2xP& zX<0@4^dd9a-7A%uBD2Y*1SI*qO@^EfIB=dCvlSGaZ z@T5jdWaUEFg)qGxNM+_*UWe5Ms@5n_x9b(69ARh*_&Oam_3Hvb$00+ja2x?jw&FA7 z?^d*vhrv2tW9@t>29n5d5hKyETX>-MMJg+gml|v7z3i?JaVJ6~-66V4Q4!`})g?cn zj@a~7#_ki2I-SlzXaxQ2)RXQe*d_cXKzA zP{*dLSv*7ClHeQQ`~WQ{-&axW7e@z3{HW!D(ChbOmesostaf8Jt9mM~*w!-f@t3~& zn{3XtBDMR?b5$n`eSS;}N7hvax4m?CuaYuSRgSP+j?Mtc`dfeOG=mG`bvyLweGEgj z^xV+oQ70`toboA*4E=$Sm{;0GBIC|ziq(!^yMZrD=g6XQVtT-`dKRn0;TJ;xU#%Z~ zevxU9EUCNhNOgG`|9+1eDsk)I7paW@$fTaiJBMvCH8tIeIbYLwWJR>;hse&J*UAcb zY}fwRpHIEwUi%#?eCfT2!FSdE@b=uvW{}6>7BM6s8@f7H z-#o5-7n&}XK2TiDuBGSkvExcj*Ywm>>q*NdM@IA_DDhxMNwKjxsMQcMI0Ek%fOg_P zRNzuoRxTNi$(dfyOiq50g2{o12zh9wWa`55(0GHNZCCcyeqQ2OVK>% zWcfl}orjC7*>xf8)WycfCOXjy;6>-={Q{|$5|>d*#1`T9!}V?Utj$8%lj2y-)&oh% zbfcxp;r)3qQ;XL);u*qs`-iC6CDq5~`uay0WL;;#+Ulxd9gi|fCWTpPT^G(l3c5jN zK}>Zg1EkfyGM=4QyT1d-w@n`v6-5fKb*lb6RE~IOb6=IR+0CBF zcL%x@qq}(nef~a!a#D}Vzb{Y`*Cq$ zW<=$AIU#So-+l%x4ppcjyM0LhDy?m{<_FK8jnnl@3!!9y?Z2q;A9&V+(qT5}nAP_f z1WEu*+@`p?5$PCby|2a6 zC6rqesT$*RTcsYeIdvk}r9Ntm!F**Gy~?d7Q81I^v|$jsA?IE-qf?VxC3x?qZLVk7 z?!###t=wuQ$$-PHjf^JDC5A^=pJi*^_EtU^E7#}LkP`C`@Yqu~$Nc#5BSo+%PYht2 zN1#S&Ia{s6RkCgwG?@I`}th{`8XNQBsX&qx(-9_ml zb$*b#X%q&;T6f?Br&ip>;xfeO5AtC4j{o7jbZ>hS1i#(4awC>G(B*e#)eb=uTC4G@`eW|_tX8~5na9S2_{+jGt9xH0x;I_bSY0wdIun~sI93v zTkt;T-ynwm!4@WjZ=Nn?jH7)P28islf7RHJ<8kLW1T75>e!B_%dZ0qjPIn52Si>u>Bx|KHpXS?}P(iaQC??Y(Rvl35Q5}1p3{gzc^58vUuC`DtWkCTPh zxG4(k7Bb!qn*{Af0lV#e2K3hHQpGd7uqnNc7>p`S)Y8$QbfJ-^kdV+R3WvukpWKh> zBEJ<1=2+_LjswCG(+_FQ=i2FG&3Y9Url;T^aH-3^KE1*j9Ue|%Y>Rjl6a(f<4no8j zTK z1kM2l6;)I@&T8F3fl3HN{*77bK}LphQmfpD*{0n1H*On*k6!Mpc-pP5sAq}KPK`aj z==q%Qz|W6+2ThbTs-nc#leM`D*$VjkFy3~kdSg%S(>>ZM=xil z-%bx84~GXa-n>Ci#D#KqXP)=sizTdct8OPn&=ZiVtSMD+O=zV zG~yUck1Cl-PdDWo@omwv)nW|sSsPNMJon<%1kBt37E^>$$yQTEOLxcIT`z zmBoz`rzx0kU6fu@;m$#TA5^q1B>MpfVR+DCIFKFRi?rNYQp0`9%xKY4nRe}i+v7faR5u5nWB}o*AIm*8I)TU`l4Q zy7#gVRkQfS=xBakJg97)p$lok!ora!E+0O&&lAC$>!7p7*Knh@HHPg_-a*i(Wd)if zP>LpSdwRP?&pK_ct!Zm2%e@B`e1NUh!tX|I!ylWCgu#rETTEYXFv>KX$ITOZ#BKDf z8jrUJv5&(IaT(4Co0~AI3aUYw-8H5dsT?UVZ|cwW>m&gnvtW{jIXbnU{FfHMMyU00 zowA+Qj8V?>^(X_rje%o9RnhW4>}4i&1)IXl9Vy-VdqKZo)GlZlh9JlD(g10}*brl3 ze9kDWo%8(GZF#r_Ew6hJAOs?lva-$@@LRHM3B@^J`~pD19QZl8kj6TjXu^H;SmxC^gG+DSGj;sl|4O^6+7LYkBOmsrBDv07()-U!|8+8J~Esg zDk?*y?^yQ`@)ix*0E(%q{M%0VP%Z#*jYi(b8mdZ_4(Qf}Y~v>CW^87|uK?@Ac)(Y8 z>|~+qUE3uQzKWSLflGT;S$;Y^%>2|*h?5%l=-3#&v{yCvnpMM)O)0w369}Sx8(#Un zRjM)w+LS!UQ^H%OCMS!fibrS8TElc;XN!0ZrzfAr((6;B0 z{|YMNChv8F-(8VyGr*vq8-el@-?n+y23Wr+Fx@>Zt^GU-BpJ^d&}Z0)uw0AhZQ05P z!hPsg+ogQBc=f_ESlP)>)`LMW`?v1AWKVZjV9s-!j^9fGdr%ZOvap@3`Z0x?`daW) zqV2k%b8nzc-nkWYUKd1J(~5mhc~9qrI=}DN)zu|!qVZmak)0h|9^MZ*#X!18J449McM<0?Kp>|StDvCBI_uP8 zk_F}kyc!hY7-)I>{_g#Ros+X`!Hi%tGbImi&kl(K#+dSKnaAg8sCr@A`UZQVaAb#B zrJh+9nB8UZ&o&b{W~2HT)k2rZ3c4Ho^tpx7zky)!{q$5xKn(AWPa<`cg77Vo`$+3~ z+B=ky=b}JMP}--;Z@u1|66vx*Xpv_kZ0GEJo^`&3psQPTVrN$J+dRX?ha0ZeKA%}L z+5!uq5WFpUnUf>o)pUl*I-S6J;QP_|e%4I<&&J+fZJ6*qz~MI@0g5Los;)Ca`NgkI zsExM~#l<=(BVk{2BtVzMjrTi{Sd5Naty68oAV+r3CpX?x{^x*W*%B>n?Y*w^?U=Dr z_Eg8Py}jzq=0h`MV+;5Su?ZPC%S&2i?UAC23Q&do)hIV=4cH#e=Umrad}<;n-~ixZ z$&_Bro3pUV#@qTAw`=;v0V@(buM-}TdgSr$_CtYf^YX4wip# zQB+9HZ}n4Cvz$MhyBP4Ifh%{kQ+d#b{auusIGu;XMdosIycQQD^W5iQ2{glLqY<6< zR=*Mm6bblH;NYU|e5?R)Dn9pq#3K_MC&GwuTJ0u7rDxJ;%Z zU{q}vYNEHlFV_5d;&S2d55?Z!1d9RDUDz5?^u-!LR~0Y(|NXy*|2s1O8o|Hr@oyyf zH%9)O4*pG)|8HOdCC4=qW)^PlGWkdfYHDr)0RtiDIVm9O2OC(F0c>D396)!1<<-E7 zOEgVs1O&gj0dK3KA_}zb4pY^(3!Q4TqE2i)7n=zJde-P^KVD`Cw1iWjxEJvfg;ChN z1hTlE0jG?FQ4X+V1SjmZjt`U?qq zp|xvi&IscJFd0kO3qd1Xl$S4TKV55lvF1v(ZCvJD^BsSH3a1;uDUOcTcXy{LDdCV> zhe=U%z;3^&im21fFTFF{^sliAqb0^or(ht!G9x0&;5Og7gisoP6!3|$* z?(UR4Mu+Ql$d%#*zZ)ECX(H>x`G!CrGs{m31}_i%0eD<4y$VzOX#<$O=Gl7QNXh}G z;utr2xIO~dyVem5!U49*9HyhcyF3V~H1nO^XpRn6e)FaftU+gGT~|Im3*N&U4z|C^ zDU)t<&AyQo+@9)7Db+^p6@Q*ayfWk^~BEAad_ z*m&c@DAuNG{K1W_S~!DxbnLA)-W6z z)xL2kwj9WUf;@qmh-$WnX^OEW5*|C2100oRio`?eqnTUixwAL`hn`NnWc1-JcJ_47 z{e+PbRak_|2VkSuCOo0Dq5A&Y0W#udzyV@(cV9SwUk(k8j4Z9cD4|xBvpYB8v*s`XVkEmDiNqN+;JHVN+7vI-O3%%aKdtul72X2 zwuTCGlnC@@Vnwr`2_vq9|i`%+>TlA!L8Pr$Cg|){zfE}!;Cowm3!}RvOoHK|9h7OEc5^OLzb3VNhe8mq4@0OuXkrK z{zJ4^uDmRDP`mwry0EYiI_9ZCRa>lpg564Itsrzwo6Dp2_=-+rzVyF9| z?*%$RCvB5`G7FVNB>r$7Rf8+)f|{}yARBtN(x7Iqx63lyhjPKVnll zK?k*wdYKCyv0^DFcW0!OkJFRws&TDtML;K&YNz~U!@6JeH|R0cw?hDkA7OCiyH{PqjphzqxLL@q8kJTAjZ|oZ(2K3 zJD92ievj+a1?TLtGPj+mp3R7qAaM?;si@f+-|iByPKO)D2Hwl2_h%+mw@T!sJ39WB z!q*0^kL1^A%2|)gX%qA+oL<%Mk&!&l?)mx3G7P{B@zK?qB!vX=D3IDufV`KKf$w%r z8Q`eR{6M>5;5n@Y08X8z&QO4E6Aaju{dcY1H>IJC?<9!b;$6n?)JPt+^uRiHhr&8y ziBPM53Qyk#&8#rH?h5Lvs=ar+=Dx#o*-eg6cIZ@1C{-Fyc;vE`2i zy0k_<@3KYEMK_Hpknf$Rc) zbI8Cow|>wZ_Wp3S1x(-ZT%R&H&MW?na05K{mnYyMCH99MQOA2$0CIH25a%cJ(e2E!*75$p(sdle&P-jZr|rxV&{S7rWV}! zBT{wXfb`yBNU$K*1_9&(tU%E@JVJ zQLM223{ncWjytn`KgN7#abz|;OiAQ0HH-xtjoX_89cZ)G-iT)+xF$>7^MYY?B2p6e zeI2=h|B4m_UgV=M2Dc*bQF?%zUpvDYrAa+wm zDky*4b=`}U!A*sWhLf8+9`+RUgU7!}nbap|&Y%rmx3;ABul(_@|47bTwNoceA#sIC zzij|O^8GI1Z4iHTw6$eFlrGS=#=koxAm$PYjK}LUGc!*F0e4{B(Q&Rg`{p6HbALSO z-VQiV><%4U^`k5|YEctMr76LpqN-1?a|{_eu5R>wY<^L2j*1<(HaZpYVc1PdeAjo# zz|$aAc&Fi8pHwuXk^AnvR;ANg=*uZdsEQF=t1zj1e=fp0EIQhKO$41FiQ54urp>iC zoYCx!JYGkYh7r8uaQ)V>mNeXL{MThTFZXAzSh)G$GigHXPq-Fa{{;6`L%K#U?(}-T z`4iljjEu!W)P%?AMjvy0PQy)7Cdy24?0C^Ywdjl9xyK=`Uw%n*h%g#J^p=aP+>M$I zI&AhKWbmbddj_jFj%e%f@x7P;r`FQh+P_e_QY%M$6ElT7sv_&k7EcQ=b~|RuBh1gE z;I5lGkHu_`1Te*Krf}v}CAWrC`Zq#A;*Mn~rC=01XpA(>v6Y6%v5N2(8!UkzmSglv z-C{v$bD20J`u|%C0QRkF3cprb-*pv#h)&ch{pP{&_S?u0duixaUcEozIe7*h;rV5h zHI0XmWecry8w^;K#OE7Y?@M#h=oOiDFtDr%ANsN*4|;t6F5klH)xE(tCdE&=jT+8T zv0V^zA668aQa2?!RS5o=?9rc0f~88%qUs(zRe3AA)62;#!x3`ocs5u}5|7H(%#VTl zKKirFxc=jm5dj-(S1xks3xH$0-LaEs1w%*5z-Sy}cZ0>j=SyX4vKt}p9np&3BUl%_ zp04Zjb{dt~pH$0bdr>e9VOOUpI>$WGv!sF|pz#G}FkkPdMNc_85!>IVe2b6~Y3RL_ zm7Sk8_yJ2i)Gg`wska7OHTWZp0@8cQrgZ8nj+=>7E3aw?9f_a+ic)tUriFeYa2>sM zi?VaTt59zQ*c5 z6SujW=w9d8K8vN@{N>9ezFxOogWGDeEG}CaGmXUKddJqwCK$L>4BnmZ4}``o0qzb@ z5%X`}l0M2?69x|)Qh6ug8un?R9v3(FF(w!3H8Z6Qp#}7Upfx}|ofpybq=hT5GT?W4U^z4nhg2=XiJ81g)OTj}WALSMFk_@{zY_pW(K)jB12- ztLG}U%mC6zl^?K&_&bK)!xwK-5VogTg}m1e(LC5gQ)-4lQ~s{S;R2J`DnV)q#GC}n zdf9f+OFG?x7mYHsM(QnxfuPU(oEOW>!Ev&sbfb)h^=ukts8I9~pYCX{e}t2?;4M>xiM|)IvZ>9B<9e3v#7DxIaluk2p*c89edj zXm%0f=QDB%A$?EupiM6RGiQF3n5%8-q+O}E6dgXrYj2X>yQdwz)csdmsc^YiH}B3b zTjhBlwWMb|7h!*U$~F;POATD<#oaa-kN1CfblCJHH!dd+03z{%_hZr&@VIJtHob5umL{KTepB23T&$}5HxKKXP1=< zal5?KQ952FN04xt^4g#CBpVYE>XV*0Xi-K|qWGV9cpSYSVh6d#BSBBtM{q4li1{re z18;a~TBYS0*;?dc_QUT!kTLJ}DM_qqp>P^)U%9E6RKW=w&%B4Hm<8b7I$qAIOge~YEezi|XRCbHV!*LqQt5^mC3 zkl4E0A0~=N%R>bGk(#ndaWc=XN&QM0xD{H*M_DGpA|>Nj_c{vgY!B+mL(-XP{Ew%P zq|_zlRP60HubIbR7AXqv5ubG(!r1~arKkE;Ta(KnD|K37}%Z>{$ zZ}!G3c)PT~3ySTjit{OlC^S9M{iroNIZRaF{V)QZ;AneGN?kgrb~Fl_8Wpf#r#BJ- zYe6wi%W!`_>tzOb7kETx_#nVJYBIBq{>zh3Q9x|`sy`d4_>($+3+*zaM@4YzO9b(% z+L|OMsGQJ2#v5-e$YB?rC9xV@C&d*xLbhR{JfSSw$iFi zBI{G*3n=OZ?9uH*AztP<6`wtzIb$tP#eQqI`~ZwN^PTyql;Ar;X%JTm-m}BuT~Sh) zfFcf&H$>Ic0Izq%a5Xz&`z3kanRNgg8O^c`t}FJ`x8R(PPP)|xR(tGz?$54L+$(q% zETc+1Kkzxi5aK*hdP+WwtYx8)qI9o-_)>D&?-{kkT;H`sHpata_!!hdvVWVzmj5v! z8*5v2^XSwmyc!08|hq5fBX(Y8NX-B`UETnyE>TPdTN}`ZE@O{@_zk1s-kKO zy;pncB4Y8a@6qa@UA9HR&1TE;tX*dB!}&~EBoh>11SAI+nx#8uzjc6ak4Ir>_$M9Il9#xHK7w6*xp2gn7R4W2BNOzdofmFc08P% zRvYJhW;qkR_D0&H?`I?aaOPK0F1?(YTH*5>I?3SJe($m2nSQwYZ%>_-brqJXsBbm! z8W;d9r)bu8yf?wS85G{2oKosG+abg(#JgI9U910iQK8TTY%vJa*SS%^UQZ!5rX1K* zK$&=SusJ2B55)KC2Q+)-Vh3RCOPS)wJAKrRJ!cu;J}jR;qPKKW(+Q&{Ud$QJG!dIpzyJ;I@wt{?c!RSvnP*Mzi|PI<@T>P-rUpUE*s%zf>M z5g2pKmq_X0&i_%4492^rIuOknp ze(!iq%qdv?E5cxvY~XpN=^$@LF0CQrrs_5Rj6$g~FecKz7XlOCmzI?sKzswb zJdFxf>!#4&eqdBOG%PA&NqhL7YNo|>XPQE!EX{=$+X#v0gcAeHdNb5)Mdn>W>fny4 zD1;~^4!X6SuRHyDn2*}@K$wCjmZ;eOWIcErR=MjzzjL&Up-LR9rx2^s70cATVEflo zYNl53zL=?+zHiWn0{6|@GjPlK_Omsv>#v|-FRV&C`7C%3<_OCU z<&BhSsVq%LKSw6JVHNz2-+gnL#N>Jksj98aFO<;vDIU?=(#+_%@)gM->h#HadnrJf zg?%1afFxin1}rUr#0XSx_41=PJ$*JgwD<|80lX?R=jzVlB#S-%V{XNN9noL@!{v@> z`S~8C#8D&QLszK>&NjJ;uO8m`59`%Pqx(f*JW5kDX$ZALO5;IZpn2iCHDuI6lb ziAg)umK`ob&*vh;mFP5ca8wXv8>}@!I6*8Cp5cU-tr$S{Hp8|#CiRij%t)2HW4T@@ z{C;Y0zLDa0`J?(>WCp^gOA^ban)#5c-7b4e zZwEvtXubW(MKs$qd}!^by!JY5BN&iInh<-7pyRj4vXP#@fB8mPU0n`NI$6Axn2x8) zypDLSswKq)J|{$h*3f%?{^h$TGd4356<<-^o)gP`m0h-#Ycwd$QO7PR*ENy(E;4?g zT0$J@d>K`%6~tzMR^l?Oa$6EofW%%Y5In#v%XUg0AAXk_azpQO*!%e@F=X$z*oW3c z(I3AqJb0^i6NW8RP$ zcn#7W!Mr$o8LNM+3oPfegiX-&T`882X^Sx$N_zk08_?+)A? zg~Z9n+?TG|b;gNMO~z~hy+h0i@HO`jGV?{N=2R)a!!$%1d0)%Kt@I{3uUxH;{Qe}w ztsE$;1{*r7)3y|v`GyRc{eNUG{Izq=76-5D(`%#z?5l@}3|gY31dadh@`zRKn-g&4 zhkz|pVh}sWt-=m2pghvZT}0LI>4uRX&;YYRVm#hYGEAp zNs>Ok_R3NG`9eBqpke?nD}5en+O8Cz`{T!t zEpB3IeOL89n10DW)gh6qq3y zLQc&=ufLYQw~X;aQ{q)dA$~j87{x>o;^;@+51!?%7_@vf{oMH)0;|I0*mEybE0Yg=>5-gD3VE zN7Jm7l$6RNzXW<9`iqA?l=Zqp;K5RE)z@U%9((=(uC zfB(6e=!GAdD`gL+7s}hZUkk|x*WfB_gqC6X{DNL+^u05~|Ej!=0l)l;FY?tuE#PP* zKTa;<>FaUe-{fy?wm&^4`XRTCKhJ`LM^)YT4$Zqnw{y4;jR677*2@Cotvzundwdm2 zbn`0l74lDB!DuxBEhEQE+kcV6IKp8_T0?ETaD5j?fs~6=M;Rr99 z?QSW?P>mbzYtP=sxH;ppcYmUzA-`}i?Hgg;Eh^>_HC%iDg^Xq{5e>!h;$6A$P4spR z$o&J8U}f<<#T`fx$Y{Q{3mY=)Pe}<9+bhczA`)eAR^;a^ledO`Vti2EoT5)tA2=PfCI{yR#1zuocF9@C{mU|$@mw5CpS%=GeRlmw@Hi386Z zGBcoMU@-q~XFdiJ(?Fon7`ZPmrSnwJ^?tUkT$$jkxQdv`dbgVe*%s-u6y;WP26`M_ z5`=Lb^SFr6(5+ucsZWWIGX4XKTN!wC3g_yBYg{Qa70^6V!i%KNL-Vp>WghMC&h+f= zEapxj%wA=D`Uet?2twQ%$P_Up)h%jwzk z^!RLXW!ZUE93Tp4V#vsQ=ySG_Gy>8%9fvQJg4ZM_<2KB2W<>vPCOG-Qh$le(Xdh#g z%alIOSttF)?Wq({FLc*axO9J7sI?Hhn~1RIajv9SvftvakRfU%w9S$xfs?lGpK;Ay zfF-K#tHncuxkg$@ss};{qL-&PfOH0LN;HJdLTxp##@IY6sO9Hi_Y>f0#Saq~ zosE32Bjj!N+{X)=WjG@A>AMSlm;juf>wgwpr8I?nvuVIBf+m|92{QJZd#;&RcakyoL(Ii-M z6Jnt`;<+gTpL6mESAUZ6r|hoBXTA*L#U1p6*1hFZTDfCxki`>q zUDiiV_}IiIogCQSxvwn8L2Zx{QMIBu(#(WJq#kT>@@oA&?vaxvv`b0sEkT8$aLWRu zrt=8(y93L{$-iH06O&><=h7Hm6MbnBI^^)P^>cz$^j2s1fJ}DSN=p=bGi$kNiZH%x zk<*hPwU$4~uj3~d=($?1HDi{3yzx_he!xCow$uLB?E3!k@mJKO>fsAIcz#xaS9Hhs zX%9qi`fV#$Z7p-EO(n+6)kEV4+ls^QwR&(>bUh(06sXQTRHpfA66YTBWCro(K{)`G zkB4Lna*|arQHGdXEuPvAk-L5^Gk{T|B3Y^y@<>W?PZxxSa0_F0D8=u&zEFt?4pZGn zsR;u#AZvCNq^dRvig<^zPJ?77A;|PeV69ct8Ho53_-E0A$-7L&Y$;Q=$8cqsj1GZU zvV*G!LE=kBN#nZr@|2j~a)t7TX-E{Fl+o>Zs@rU$ZJb~(9jz(ReHqzdF;nXVtTJ-1 z?_k^HGI6nKLJGm^G+xv@*VW(*$m@G7##rU+?5!5QWW*B#f7eH+hlzG=bj&XCm^lLY z#!LsrBW_|E735zBPvFX&p_UJY*SN8q-7~KiB(X5ua42E*JYQn}v;IC|U?v~9H`Sg! zsp`e_kXDGoc~M(izeN`>gMev&+_Ni@u!SCVa`tb~vnm-jU-xnWxXZe)*8S}{1b4w# z2)IZoEqwG^#V$s6Tx7;P5}z}^KG;|BS>Ou{>TAr;`*bCd5xc|SI$2;p+tkV=27IFe z(*9NIK^PN(RYJmJ!(gzxk=Ee)9Uh|^9F8e5v7MX*k_MIY`K9`v6UC+kFUPvr^y9^! z5K81EuS#Ei+aj;ugl^+S;b#1KAr&w1x<{GkvpSGyj)&A5;-!#qt^xkgwrum`hMx7G z0$l|-3=^)2UbCE#2CkUvRS0e2;MB~KzrG4)dxXvW;+vxz84n8K^vVL!tNOHRxWWuz zhM@KDAM%?cXio9$A81BTyv0EGzzzixBhxNnOaPX#jCWH7M))nJMH1P?QVA!U61{8l90-dOIG2C9Zb70Sd8 zH~M_1oVFFIy_WT%EP2gyJ;7^j1c@T7Z|;V6+=@V~(bxU+R2E7eXSgLw$2}CFOOv!q ziY}3YnwGlGctIvQOC2<9VkE1~lCzv|8qSx+gZL-V<> zjR$$~3rmm|^M8~N#=;~>@_RzX`7y>mD(S-S5Gpum!#uxAcu)NUcos)|(~AQ0CP_!g zliC55Sw2SCVV8=(Yh3s4!<1g62`K*}k8y>77Krh^)r@fl0KNL zmk(|A!J~s&(AC%1KfH+2-!Ux~RhjbMnoX?2PFD7%Bkc}}MfAtuub7+`laBYtP|#Zk z+F}Cng*6)yTCc`V`tzcq4(_`wjn4-o4@~A08xuBD?n@pmru-#6kibKWZv>MDiEd>8 z;RmxrL4Y&@5Dg*+v#w|fZ`?tH^IH&&*p)AR{oG_2Ez9)wu_#2^a+)63guKT$rGuna zy6d5d=}?|tCCDHlEhM(o5Js2gIe)aIj67<3FT~7wQ*&pWm?0u~Ymd2C(EUZ-#XsRN z8X|fcT_?TF5e0Cfh=Q6=Vsh7=x*r~6_w5p=YE0X{He&q!YdB*>tQKPJYIN4_L@Mx> z|J3SQJU#V5<}|hZ;5P3g+5jd{^f2TJstBU(=DIe@HfJgA#JOP}o-20BRti$6R8qZq zH3;_^-Sy3jq8mvd;2Xf#ZbG2+^a2L}e;95Qa;}U?d8b1KT5S;Bm0jys2{+eet z;sxAOk6CgdTM!XOXc;}mt@-m-AIvE5YK~~iZU=<820pD~02L8ZV0<<7!uZzUXNZk11f@gfS5z$g$s1nu!$vRx!nYiD7 zx^`7d%jI=%Y^4!?CQ@Ls9`gN!O$sE}{@MFU%IDl(3}iEjkl)StQr7B>BIV526BIaY7wZ3cqgYEby1dz`O;M z3@mgk&?w4YJ~j~ws2g|u6dKu4O7dIh$;c=~4!-=p@EekpGe9J1wxo#uT)#WBcbRG` z<{-ZrzYFb(A}bYLvR5E!9`De5M^CazDULkT>?{MH1>|Q-ar^IQl`TdJO#}BewYA-b zK2=V}>b@&r0L88k))s!(nv*@G&5SL?c??*9WwQy!sY)b!Z ze1~b?{nP2>Qb!pnMsAxQh-sIZGy8fgWwSx-nN0ewiD zfZQ7~XL&3(sU)`M)E^>8eoZ{pjP^)q4LpLG*}^9=S?DgtLLsrEQeL$7)%M~&V#ccO zU0vmRm1kYUIk;E9-}_OuPOt$BIb+EMP@J zfi+j!M#R?8`xCR_67#VDp!v1LjwmT4{&NH;xKBOP;#QDk-o8ETJY-N>k(kEpU!%)! zz8A+!>jh%J*4;)-pK4#BuBkJq!Baegc?>H0rzHe2L-~miOTZ2L%B52XCki}bZXpOS zZ6jjC#(>a*wN_Z(-ngu-uaBFeAcz6>2Fhxz%-+O5fCdsF6AOQ>EKRVp%p$vp{2_L=hZp}AF2g!XFN`Vx;?0~B&6caV zMksHH4U8ZIHFL{hiD>RCxYab@OZ;3wg3`SY28` z^LF(;{E=VLpg2fv3CqG}fXSBFTxVx)tll3LYo|HE!Ja|#K>Mp#C4^}_=-9ADtojcAJ~KdTU5a5~6~G8NYg^sy{fg3YR6_o)pbo!`*!> z-h1kJUuKyYx@8~(KPNsBeou2ElocN9K>`3pYAwSMZK;Jtld}w>7R{1kC3aJiEG#CF ztOm#yC4`(DQ=9aL@E>X#7)K-p1a=GLm@uV!s0IT84}X#tXq5#*UM<_8fmc)<1`4I_C&7;y}i0O1@Svh=jHv_pTUuXWtFtyx-t6tdtP&55kJ zfb%5pEg*DoopeS+(q9wv3~boChL&NmNn#9F`S1q!N%enKPP}>T1rnZ=#Ouevo7|eD zqO=DR*V}EGf&%g^#c@vA(|V_GN5WQqZ`kdZ_f)6Z6 z-#vB~LEH;B7`#xKM(ID7ZvL(0E%*ZiU;iI2+)Wj9AziKo*~p~V{zPHEFFC^?fT*Tb z<&LLO!p49RiHgXi=fIE$PQ+If*dAAyCwi5vU;uO7=Hjv*$6M2vw0u)#2k@SN>k2FI z@HAWKjK@y)@3+?2=9JQ*)B=C!GxDd3y0`5nlown5OoHH6wWky`So(W1VQlk>8&JpN zM;N3vLO`dKWBAjcit^?p=OAh-AVvkF2-#ZG*h8u^?};OB-VSigE9A*a)?w0VO4j3L z1=dvqefRI9{Y3)tnridw{lIaVBLq{Vp>a{Alg(nM4F$r5t`O6U%G8a zYfW9!CxxyRGA@_BZ*u(^5+|Epd|Z8)F= zh=CyCYo!jLTTGf>t>CxuBtY4rVM-F2D;Pjijzc zL`kD`EAg#kXY@Wao#Tj(j^;&eIFwJ(0fHs!j`nuvO4)$N?0a*3vgActL2n_iu2~7! zex(ue*m-0qRzXlmzm!e|G#$<2_ZE&RCfA`z2$H?8R@R`X;`zGzB+oF1HCd7OiH0#- z-s8`WFTm(#FCqjUh}+S=?~>rCEtw%GWi;r%EV1bKCLwWt9xIVj2x6};NrE-_RzQ{I zBIO`8Xl-qMf}bY(1tcB^I%sIJRA?~fGEwQ8pPeZWg1&nog@N?DIe(YMSe{LOLbYWH z`lSa^ybLBU!Ye*HTBk)_9ySQdp`aCtf}%o)MZ&XKSu0|kLWa^W1Fr;vsPVY*1PM>G zm+HgOe4xnC2Tqds!-^Ay(6U1T6@`WBtZYc)%%RcyDCXOFDyXtL9A^FT`d() zghQ0;L7HZ*{)SLrB4MRc0;$dYKTI$pcZa=uF;aVvlX)O1!(i`T%Wh z*AJ?jER{FjN%e*E-EpLCJwqGb;n3Euf0??W|7SacYllONs(nj)t-)&}c`gBJeLj^? z?MDwEUcIVw)+}m@$<;%tsOs86wa|9$_VGsZe1|9KJ=e<76TABJo}fj$Z@Lu@?D_fA zCy!oU{n8Pxa3?JN=~UIz$fm^}SK@d9 z&+ORKWTDMpk_N+d%IoDWPL;W*I2fFK^ZD~{{bHGI#cIV0l^ z2^2r_>K0nmrJiFB`TgU4nt7@(@@ma`q*v>d6bbYYkH*fAmm78F=H&EWd+epQI6qH) z>C%HbS~VU^|GlnCWN675k}BE_#`?&?$mQi__ic0_gOq04$x{z>+(!};_byyyKyYnF zb-E=)h>Lm8xD9`~8)0~!1m;&ZA!v$TE>Xsq$VQuQA8w=5yW$cq-FsVjgXB3|hAXv6 z9!JinaK8sH`9`*=+t{DRB&}_!azwDP-MV=A`ggFWcdGImZ5W%JZR9K{Bt-EK@!#Ed zR6$eT1nw1|yAjH4Bv2{pC5hh7r$NZD(eT$f$0s+xDczn5g?@p2PV$Q-t7GcA|B`%( zjjVLoZGDy^M=izqJ@~!xfQiiNtLL|ZJ!0HnwGf6r`;pqP`b@Tp6Oi3{ThYCM)-@J>Yc0ekxGfu)0v#*B{!ylFXv48aP(X(fl z4~O!7jGjMFDhhLinQRf+T0E>q!X7Ibo$?nHjDP?60yFhNe=&5L-EH)mF49y{WkS`T7t0qyYU~cQ-(0&spWFmK%(ibSk+8^OOGq(#Kc5A zJv^RRvM$gxX;N!GYE`*0R$@1{JIh14He4{9^wtL$E0~D6Zax{T{E0oWVtBp)5|BRc z&c14cMyZ_;GdXNKkY{wZo+c&TUxa$cLdU{cs+pEXu&Xsp8Pf1wAjfP$%=jS~-=D^$(P)JHk|BLpP9BOIts}Me#?Sbh59iCC7BvNaKn< zGI>ut8BTnVxwIUS4L_Ob`H`H#Wv1UR>e@Y$*Gr`e&6L3(d_N3vYG6=0%I@^~g%&Lv z#`T%Mn-P2qqq|mc22yRiKN{b;utD*oq~xnSt8#*Pxfi%)c8=YCms{1S-{m)!W7(2b7 z&5h}Ev+M|(?Q0s>AD;4&y})!?LgHvIevIS?U`xqr?C|igMVo@HVV?1TPV|p2cPVe* z=1P(z6q&VqU~)?>5_)~@O`2sC16B9#3L1~UA8|CBlCM5N5?gA`*o}Ns;Z~lfwsRHd zGO0^hpj~Cr!c2K_g}w|i;^516u|NOYuWywu>$(M&XZ&zSffxXY16L{Pvzn7f9V6w= zd)#j%)Fr>|d-sR`#BQ;=>?O7j#^0lMuh5w}IM-z=npKC?{|M~4^vc>%7E%G)B~H;` z2}yW)^D|z3wzY6^5iAyd`!6e{*Kx-XEAB9-tN!15^<};8H%XZxYLA6&&GiioB=lpU zHcwUB{=Rwp^?Qi-Pv+vqoDECO7`#}|rmNh(^6|pOo74WZ0y!hU%OW3M9Qj#Pgy~5k z<>Va*15}tl#5uzxWs3{{eMkKaTUljgzFtwDB1VwpMu>dP!~wV0do||tfFp}5yBaqR zoHz7Pwi4$Q(t4%kE@p~1z7Rb2JhD45lZK20dC`q_jStizd{^X`JJk8k*xA@HT)Uj9 zdc9*jmfP@W)cfu2ZM%sInXvc-2@g7z_&{k0Z_(`uDKn%aeSMCrF~$9UKS?h#sJMTI zTS5_5x;+@GU+EpV!^FkKMZ>L~I!<|#2Qc+0B^Kk*Iw<@K zt1U=1DK0MNP=4lbA^lD|W?0A=lXmpQN!%~zjH|tUPZ={A$@_n&1EQ|6BGz!_?Wp^& zn6HbOeS0C5;QZQ-iD7L*2M0ZBFy+koMY8cC;UO28%+h5gGsY`=)o4*5UZhr`|uW>L@60 z&i`{B!ong$aGeU??xFKWr8Aa>=qzidu^$s2S0E1g&Yh;XIJmH;!v0isxLnN~Mqt8W zgMx-JS6fa!nsR+58$}H5aBySfa{Y*-`QzvE2bUrWXiRaQN0pV8v@|p)YXRT>9`=!i znH9yy{I6?ky-+Ks-prUcij<4AhWv<9%wI4ZrA(su&!PL2oy~df3JuM7^HglfwgwVW zFGRo75@b9uBFt|!_;gJ9#EBCxKjr0Att74>!#~u6m-U?YmQpEi7lMqi2xDJ@v01Zg8CV&()py&(*Cfn*41%++>l&UviDw=C(2_ ztL!5*fk>)RwJT27D^bd2Xzy$p+^&v@hnzdJ<;l;VyT;j&sUKh(dY1N|05cERIjh0eff^# zTc_oIKRWT)tM~3n-o%d-ne%U6B~f%Eu$WrAj^IH{5v}1x=Y*w)Hps4ywgCcltFW-J3JXo^(%k=A zmoUG*cJYgW{#R<*S=oRE7kJ~Oz)O-4-T)`(gs4o(&*#NdY8`$I3{;FAwE2CZrch3y zkAt4m4*l)cx7(b&3II~d!yQ<5Wvg9y3SOiIrtF56Qpk+7;x&%Y?LLU1 z#ktklZfep?2YE-L|7{sbQlW$r16GTg(~FGk{4Vmt`A((I)Re8Nna1q$&hPlUT12?< z`GLpYV);7S3x>(2B6`#HpWNh`gFy|zjL^_sA1$%bi@ztNpFw>Y_e@Z8vQt{&Utx!p z-xc{oOFQuymo;V-O6Cc@p5_Hg_W3{3`nBBa8K#HK+Ip1*JL-xulXb8WbY`U zT?-m~sG?cIpA1k)vkOM2cSA=FaV`Xun2@~Q?3L-Q`rX;drbG$hJGO~F%ZjI*#=lc~ zCL*Mbx{xYrVD^sJUBjgzG9PuYER5c| zly%CC>}8pnr{H!*{8>%y(R;8SrIP_H?Po*&4g4=-txP8pNV1ZFGyiARZDw;Yzj)!c zgU_+&kI=fwYLBo5nHQ=rc79qw71I0w-7@=Qdkz{BmC1rE$4~F*_L7XE9x6Yx4>|f@ z+n@a4^vSnIq*ADh-cU#HroSN!WHz{J7%} z3k!B#YIH)OZG#H;w^wT4WuAUkan0K799!?^|EOKidqqD(Vq1Lq9Dz&$`P3F!ru63C zhgVmdo_>=J%cfGIo;(PQYkI8*I!&f^;Y_fOi!;ZS^O+N7XU;fq;@?}v~s*@4@X!kl~C^~7;k?}K~#BN@QjydP5z$15G$%W z^gl-{F7>UyT=yF748tK{KB?GT|^@4(?IS=$Ko6ul7O6fhGO2 z4+HgU7l-ef{@*8Gj!eCSTNv4CARmCu#b*wX(jN#zEmA1l@< zD?59@?$Y@WWHq`EY_6v&o1dEZUhv|`SqW|j^7DuS*CaIVw8A%CXhc5A4Lg6J!yfbV-pRzAi9v%0G~Eq~Wy z@!arI|K&i{%%2B31x;5Iu2@dc+CDvH@ih1D1s64osJ~{r%Yy|ye_D)#gk;)ynpH(r zb!%71$V#ml#e|c1x%lO0?5o|+aiVxAt+iAmk6)NAA-xg}2S?5N>~8GKdqnuGM~dP?ML2QQngE zf{9+)OJhaFz@uGK9SWEA5yd*D3x^LHwetVJJy{K%GYS(Z@$EDcR{l^d5Oq~kB5wfV zbmHanKf7=S$RIKf#A?h(XC0Vsh7rnvz9mVeX?))`ub!>E!{7e*2%A@jY&*PTfF>?P zaGm*#^?KqZ6u(Pmp}7~ym$rE{ZydpCJRRf$OvtT*Em3AKW+v>CHYMbbwoA%v0O|GT z^F^=BWrjig!>@Gy@%F!mM&|71c}7{}a&pZ8$G&VRdm?rBcSR%0w*cA!Z6H0EFgiA- z92}#>G(GBU1p%YRg^t{@QpwoeYv;&5)NORXW-(VoMrEJpWHx2KMC5->uILP7wIFF{(e~)U+MDwI|thcBynWJ7+eZUOVdl3?@rN&#l(nWwsk<#y^Hp#^BpWw*j@^smu1c zdpUD|JtY5K-hTiA6Or}w?o^JJNPluhnZ>J>UTA6LL!;euT7o;`UUA*s;r|7x<07ec zj?!+tP<`YfE4nmAz_oRmcP5;>c+op|z>0>3hNv%hTx*BUB9GUuOISLM4PnqHB>0%O zS_e4i<`x93hFDoyPoF(2XfvwLN@)OjzcnM*z^`>Y@s+}2B5Luj z!4QL)UQM22SNpXy(s2XPNg2177s|7X9yZ2Z^&DsFCMp7U!nd&5523VL9ij07~a9&2^wm9Es~ zS1BdmFTCL%gd&g&aOK6@5tY+paHhXXaw|Mhc#&)pK2fyjCr#z6mJUVGG2(zx;aiwF zXXu_{-W8u_8-+d8SEeBMMMP_k410(nxgJ}*UYxA<+-6)jQTu&ekUWJ~9@xy;kzuTN`YHt5@;!TyWyRmfLyR)a= zv2I%G>cC4UigmqmRfBq9RFi!66JY}$PP z=}SphPTq(401LWkGpGp4|;jScN07jlvdOoE7|nKU{(lUpC4t=dF| z7bnT^=&<48@#oG{ol#XFDE+D6+Q>67A?E6e$ui!N=ZNs$UKL_}OlR-pq+jBslQ`+c zkI%}v+xC?wRtT9;;${YVcYE9;8x&ofqE~;uAz-PG%(w2|*@xa9w+a-%BEL!?Cp)KC zWl@luyL|7YwT5}1KFZ#!fALusZN6dpMY0rM)si3vu}-|T%cGa?TZicz1D5Wy$vqt` zL55d~rYQ%Sv~^$W3+)a|2$3{h4sSjaEP$t@pkP?vYmImn(1Zj{)x*O@PZ?M*S-sgGuyZ8Qn?{)A)IAE@M-}Ab! zYmBqTrgWEYoCL3!xxSJo;A|-eyxLJMCx7E?w=kYDMYb>nPwe)SnfK&tRyxCyOLNOE z`ob~{PYLdG?HqB2f`4fN`eHGZBv^jjE7q|NG{)s<+zRM)CQfbe6tCBOqpMr;wxLn$ z-~#&zx5@$ml^XFFtjXu6n392UChua9-(_Yl^|-hP`2|_o*vQxY8E&xL{n-WQVUl#@ zr-*w88##|3O}N+&FA*3>H6tV7i7PSa9wo0AWT#ZnXKzl87MY)KZA$gqlQi+4ZfFo| z`3&Lon=AE(hh#CD=x-DMeMpgzkPPK(p27#8m44X{<%Gj2=9qGxy1tN2;Dh+djc>+3 zsBlI0Z{$weTiJv4#Q|oBa+=K3L{Rv;94sHtb(2$2i}W4waO7Q;O&2QIP&eK@ASV#C zsF9&jsGFS0yA#W4_PSC>KhuFv7GJ*q+sT2p%09QEOWEILEfE2pm(cyF!{!=AdL`Lv zNmQs9Kis@6V1J~dVifgi_O~Qr**yDGF7A!*O6Y`FRWB@Uv-z61Eem09>L(r-FkQGl zw?={~62h38TE;{YnTrGzl$F8W^CkR?ikez=WtEdN5vzQgF}mcDT#?GzaxX=wsGhcQ zufoW{*jViAuQ|6D2ysmcpEBSrEiM3SMNCZ0#l;1C;{GciF}<8S^j~$}i4JN%41uZx zT3I<+V-1b?+S*zN49oU{pzN&d?6xyLc}ncxBO~9oOp>iQk}edKlosa~0e@G$dIOb% z4jZ1JzfrJUTCP=8R0M4ta9v-%e96zxr@J`c^K}}WliK**o_etbz`C{^7Hc#EGxMf# zue?)y4Qw;g($cX? zpWlphfAIIeQ5*RRQ~&zu)%TmpqZ8SYcl%I_e?=WZVh@cA88!*jLGFz;L%%$R z?s_5f(Yek;U8B```s}Bul@31=lT?76i+Q-QQRj4MW1`y5kixSSLxq9i1u>1D zdmn12I7KV+dji5-j*l!2LFk)VU%$R4st)&>7$0AB7bv$`>Mj_qeJ!gBPuI7)B7M*% zEiNFxQ=mIP-`O;h8S$QIe8&F%*&<`$_Zs&14EH~VHoX2ofU};p6$BC=d_j7unSx>W ziIoW{N;6YaQz0{Gl%QX3)?q=^%$(j(^Lv51j)#|W_g?WfEdv7yuMQ2GF0RP| zN8+BY0D;PBCp;oT=T+QBo5G>{KcOnFp;${~z&3(xziQGceS+A4fDX?ilx*YdF8)}^ z?en%;4Y~Yoq5Xj9=>uYNp4P-CPR|)6C1vG1d!qu*$s z+P?SJzb4aq^{DMw>$DR_z$9$H7sv=?&nut$pkuH)6R_SqX!Seo^m_^D@OE~j4+OzY z3j0M+P!QaYSkAmD%`uT+av+tzL`K%1tmfo1<-?@O6sJ@Ly1cJn<)l2!%{R1GJRrLY zHlX^A!!p>eE(-E@F^GR5lu_RWzO0op%0JVz?|H6`ht)Em&mx)o_DQe;@Rc10w6E*^L;MATtPQO zf~v3|0!(ROR09SL3i=E8V~2$RIIW?)C4nBp&3;{@(pkR)Q!O97$gV}OlyrrIkGDQx za&T(OGo?!;utvY~05QzE|43XM$&bsieS69H5l$QBI!u=3Rvm{T@jl+-kQY13}@>3hm-zH$>{n& zXZps*+3x&;f*x9Db%}@PxvpM!5RTc% zx3ko*@tfYpzgh`t4b11C+w?k=84@2GzxU@rLS!q%M1^zD|9AlWTPT%JOiT>Wi@HC9 zWQ(W}A@T9GMr|o*VN@SqJd7cL*LFYYTtT~wlchv~h(i1HKut-rOC4FKh?|Sq;iz?n zn9EY(A`T(+@@4av&GHv5FXY3BSJVDYhA-YiKz8S%JCDPkNSl4a7{yEoc!T|dGVaW@ zxNOY*QX`oMV){0JC4WF-?(_W*CLEG%^%wMA+(+5CZMTY=bWdKSdn89jMkMmO(NQu` zt~h?S4j@4QEUOdQ;$AvUA1z^jSWrDf?$xsKQQkNdMX2$@3%KcprKPp7$95{69BKql zsmvB)v`cj~3naVSHjGfUZj`R-Ln5Z6|FAN&bsEUCgUzlAk399j8KkC~1zwx+3jP_2s zVPX9MWUK}c4-R%|abIqQ{+OQvNZH-2W+RHxO4x?BaT~TK&{l^HveM zh|dkL5de1`?CpgQh;}lra#ixcXgH+vn~3kwzGes52qubO-rH3!dFTl(AtYkVFD{^8 z_4HdX?H37T-E(uYF{6)W_Ew?kma_o*vC+@RfNT_B3kgc*+^T3|PbS!%pzNDA2|4 zHj}Kr1da%%8}su&d4w(Yv-T5A(f;@%1wJR$#GcQu(w{f_{;JoweC83df1j6%2mz{1 zqG|GJ2-206wb~u(YD#-y{UiB@@A$-o$K+v&C}nYZIVL8CWq(y*Gz^|=36_o**uTGq zgn%xSS+mM<40CKR^j1J6XRh$FmuYmN%8yR+bkLF&LI94RR7m zB14DG$#^!0Ab(>W7W``o;k5j>1ijZsX>e~bg$4_IK;U0s#^pU%BjshvtLSgW+X&aO z}KrR{yX&_btF~UIQ zUJkG>1}np1)Y8uT8!vrcQh!R^QtdQszuon|@%{2jZ~5lXsD!62L`Dg3!IqGpH{;F2q1d7(p&5ba>{*wloxQ~gz z+4PTvq7IJ9)XN;8!&fqmRKtagM3={VmUwx1WR-!K6%Y(#z+U+s{uQu*FXH$|69Dk`g{9TJbVitER@s z=u8y7bPwGd?GzgT!f;Ehfaa$SPt#^^9^6*1Gpl@#b8}U>QoYXfFdsMPp7n**a4^v7 ziXKqc{i4QrLMu-c<$?bDYu?_n{6nf?kNz@xTkAh(JjHtfg?|eP=rgx~gkUr1cHCRO zivZ~qC7Bqd+Y|J~2yw$a?F&^AEPtQcJ^FnEx*{qlR6_5D(*2nE?>u$-PK7At$w_R1 z*wLdx?}iG>+%c(Y_f^{2U%0 zp2qK$fdOOt&fXy{!Nh`(to_?xyE-@=+L;cO8c*CnP-3^06W0$CW?p#L88tkNz~eyx zIUnHXCtFu+zyCTy62FYIti-T8V|4%^J-duogiO|5c;jte`|89>c`CWXBt`rde2V0e zOqyS>Hdxc)f>8TU-rmhA*8{?d$_y6?x9d@D>oPIbYY zTNCsc#pR90=FuMzNmY9^Ree-a>Y(?FhT2@gRZRVJHSkBPupleoj%WCG2C# zJ$7Rh#t+r)|&o>TK^U159tS%rfbSt?)t9hOVZWPsC8RD4LVK~e3)vzOCff& zHS>dx;`UEO0SG&0bqR-}1wFyb1xR{Gic+{TOv?oMrW-7pWP)>62I>*eEPvTM=X zSsM+s)~p*1Y^=AZxY#Eb2gh?XX$|jE6&T1OkJdDBbJq~$$xeN9INs66MkOQ3?Rv@a z9CC4M?I&I%po|xj6j#(dkBE;yhtBZx*6C(#PxBbk^m=6p?`s|6_$5JUe zI=ZaY7yUFy2on|ydntQ2`6J~Mt>*$HDchFE=qSo#CEiw)Nl)nWX7#d*i+6-qZ@Xb* z;iWjCs*`J)?#C2QX)YvQR2Q{SzssH)cDC#lrN$9!pj zsS{rG&9PTyv#&B>NI{j(ht1T~WIs5ryfVW#S>jW)X^e?}oU=IrG&;NZ-PDX4$ud_; zA#+Ba|0Lue1l>jmX;LrIG{CjTKR&+4*)-aoeX(1wk&M}{aaaE`gh;!{ns5oK90m4u zLR=6U`;)He4994 zF493WQ&o*FbNMDKFK+|o8j**b))#ZVPZUT*;ap50wCEo<)@~D(1yxc0RK*>HpO_y& zh$*AOOWlbSo*~mA&gL3}iJuLbYfM(3yni<;B|l}Sbj!+Su<)lJ+UvgcfozIS&AT^Im&zm^Q#J0-SK=1 z&rL?djqip%95}pPzk9%^?w|l2#EI_r%)*9+P%^yz`s8wYe}CL1#;@x}OW&*scVT5w zMp?7*i&P{354IfVJFw+)6wB9c&ICj#Uk_ee!YHGpxgduR7Jm!Ew75qiN@VebtL{}($R zPCJDQzy5apPB=n8ziAIbUm!mR;qic1@r5Ym=XDQRL2{HUl;M2=o=of|jr6^1Kt!4Z z8m~&?R1>qJQANH3mC=HlAIV-$AVo{iW7pxFg1T4n304n|8=?6FbqylPGO>;*xZ@4R z6)mr#g?n4!>BCSN7X#k9{?Pxth(^o$%QEfM#>ln#k8N(whrLVL-@dV9J2Fr6n?HZe zWjUeu$eVpDO=3m+h}I0tX@K14WXfn1x4ot0+sTxNRK~w=e{t$F(sas*R{@E*rZr1q z$wTp)fM!9n9wulW0ag$H;>=aD8>a_K>x-YHKQc2iri*=*ph`V1ozbyI3)y&x2#E@( z!3$WiG(fc@a00(ipC2Xm9U_9J6>+5dg_ifxNS@e+2)BaZTMp%oLUr=Y`+wFDZFqTx z?b@h$`#i0)fN0+9WS;U^hQ)azXew@Z3VNUQ=+?eTxxAgD@f7C=C;`R3x3q}p-w9j$ z`~I`S{eK1XTC%o4jy7PKm6M}ZuU%W#5`R@7)C1kwdj-|?oKOdNX~Zc?ze(?AV5X%# zhPttI@c7@%`>0&sTb9aeS>4ZJ_XezneKOVh$giOWmM?rFH{TWS6oXh8@eV@tc8-Hd zSZIu}w$tV1_7%+JMGm)Qm8}JX{ z5EL*T<>ljH4%W!GF-5;#iotU{tQXjo4M&l@w)qC5l66BEYr5xg<}V{Bz4RBhjV~^V zm8x|%>uQM#z@GAGUHbkErjILtfca1$xkDL%zMj*tyxv@-nyK0SWen@uu`zon~fn$}|*S zEQ!q^tw(xSTYJ8jwW3blhWaL7GRkyue{^y(;k8kIF}jwA`^X7OshrVq@d9tpvP{PC zX|3oq;7Xk4lWAsS9nA~y23|@Ar{v2}G|?4)#`tH^2zOA>KT5%&I+-dQ#Ra-EQ2L|t z3I>p#YbC0Qp@lLuw=i!jJNZYnzx===xYpY7j<(hv?f-|s6@J*uGK)=ycXj0LT|^eB zkR@VGf%sn8h8f`q5M(9B(w|91k}_^6%zmauKBRgHxK9{?`^CZ%ND@GpUO%+Tfq@YK zE?B6J7a?ed(jkb9qiSP~=q!WFR)Wd1^n24(x8X+NA1Oy{sjS3aDU1-NO zZ_y_b24!3J!VcmW(C2s*ncB;~&o|GUmcBQjAPVhHhj22bysr5IhcPrXU;xs5>pj%; z)gp17{cWS)_y0OPZ>^Q4r|Z9&5}G|&8oNCO1AD&h8LP^@#>OE{oqZ}wyh6AF5LVg+ zkf#a!f*Z+|9$QHb12XIguN%I9CaSqMwaE}>Cl>vHNSvLY1ELJ|FLQ>vf|V4A{3!w1 zJiNx%bKMi_X_Q^*WUHN4CBJ1azYDHh`(~(#4$q4VAcY0^G@#c2+8;<^MjYvM<6jYM z!G&^_%PfnAq{e$uBmCRnFHqzKr5=$N0#X9bhn(?3=Y>^(Buv+eKrNmNCBE4DX8=bK zt?lijpG#k!mlcK-dhgz9IUP%%e2ly(60r~rN{)SEl+?X`tb+018jev48^ zewO^UBZE;sB?EGN5JDE3e-0`(+POeR9q#Mf5>sj#Rlf$tFiY^T8yHX>M~?2caDaLS z+I%as)e5^)B}8ja&!t6&1KlB3d7iTchXvM~{07@f1I+?KvVE3?#J%OBR7#=SxN9#K z98-8p_|}FGt$gMaCdXTdU<@*lHqcLD{Y`2Ib}TL}$)ComcK+8b z?A>X7_4h4IvTrUT%^YnCj*+xoaa~`^g`L z2}FCbTo#&JgUE2mDQQw-p_0VVjEmgBypu7`Lx2PeUX{&oBY29O2>UB5??P`Ug|)Tb zY;V)g0Zr<^9(SMps{ZPDPVlw>58o`EKK!#^!(w+X^|c{xDT5oS*qJm zsBo|lLw>It{vn8VYHDgSUrI07?NHs3RDE}8^Div`Tvzsu^owwqpN@+g;r|MVlJ$*^ zS~bVv@<%6N0zW%DgPx;OeKJi9huo5Up+7+Lh6KGQfV9x*e(u8tCX7qD+?mm2ILx@W zzZI`-L#_Pgk&WYf^xHZ`N4=>cV2x_*OUW+GE-&XQwWz-PR~dCXf0%=VBih5z|HVUM z;#fRQ#(~IC0#A^}!29g$lLIIUgfVVG+OnP#>Tr}Bzqz2D=M5GfqNkeTs4J!Kju+(8 zk7;z7jE{Br_yN60B`!9S9xuS$%naTY91F{Y#Jlb3I25i!p+9eH@zSB{Tx-(VK6RVK z4`>GDxos&-@kZ5O1aXfM@XMkUKbyqsv6`X)To)a$^7Zl3Vg8#o_UZuw*+~_!psC9( zel$!X?$&R9|B%JYGsEq#ug$y%Z_;V>;jmjR)9$7OVtL=apnTG~W5Dkp5_%b!?Y<}; zmp|)(jxfTUKU&%c%4X(fhTY#&GybNXkM7tjg#7#j{DYJv62jDU&9eTu42+mm64-w; z-zk_^JUSap>^nF(fUxb+QC_1rzmAILiXyLmfn6ppKHe9TSgJAb$dT(>ve03ZD1``B z7>o+61Fp`f|L$MBHxbr<65aoeGXH;w4OfZcMfyt+P#Bz4RlM57()Quh?om22HFd`# zqnYy|n}cb_(p+Dh$5;8!`oTM^?d_fMaSX2=$L^rs@ z$5#)2H5ZyXPPU0>2t0%Lqr6TLx7tnTt>00RSp=~psyY0T+O0VIBe`9kqmOBm*LmHV zXJDwUxpaq)5aHx%9d0K?PO)(9X-FjT-&zGx-Q=p*W%_}?3o<*eAxCJf&hN~cIQhM} zg9zHRxAoUBHPB5Z^L(=;%uQ6_UR!p@|LYq3?-2LRCfe;L44&fLyu^ygC^j}WbD7CstLRu*LcLsU zH)mG-ro_G6fPeto0`j}R)dG_7BzWKb`;9_H?>m2+Zqr2|*laqK3RIn)-$z&OS-DFZ zPz|(Y5qFr*2h*=1z84$!f2k+IQ&FRLSK(C)afSwM5l_jo#<;f=1(D``{qq=C#FYG_ zh$Vpn6oe@;eWE;mU>J{=-LfPS&hyatc(?PPcG#SX7)ZwK>}5gzqLl3wk-fR9Y_R>v zXQs2*K*umo8zy$t-NnZlGlcn8642(zKN_Nre{-5_?nEKNJ&{^&PSexC*$J2(^)-wC za|tSU%`R_zz5~@E7&x>}Qb9S#2eHtlg7fQDllh1d(Hzo%)=a zOf>W~PJF1UoLx6wa1YvF^!C>ReI&f4tEZ9${ZVsTB=X`SXM7di zp&UZ!jd8YhbxvM%U?l?aqW)aThFN1P4PkHjKrouGlIBouo;CXw^L0d=`8DJalvhgYG^8JP9Ri=b!Bh((Qdq<(&BJQW>5F~yX z=+zQTIt6G5UVnd5gYaF4B(p#tKR!C>D=s!q!|JO&CP4cDk|pT-u-$(g-u{{W9Lp=X z(3A_S>04n+&yJ0Mkbh-Z$*Twm# zA%1-2ORLI*-sgdq-%4cmY?|sdA2W0jv2bxEl5xa)J;H9ga=+GVQH7ZwYqH=|-a~Ks zMEFBThlqECX7)~aUhm^=&tU5iL8l!ruQffpTe`pp^I&=0=U^7_WN|QIdtfYe;H>ZZuW1@FS7L$*S&48&`&(2!7$20|wCtU$qhS`(j z$iz$cq{>ZOXoNta8nB>~(NY7aPHrZws(zgqLg=JlMT55-1QFC?Aoy**Bx87)cZLEa z^lL@MtK#iSUUK~%h*0YLE&(+yE-P~%+caM_s8qi=JGQ}gUx-j%)^Gqlyg;inNJMl@ zb=PKB`!ei^tp)daF~l*{h}EQY?>})qqXD07P0bm+oSmJO`QTSv$@cw7A9OH-9#fwt z?(A4YVId+SvWYhdZ+|el%vn}L!)^1Tc6L^$QvJ`!KoR_S6gxH4x$P-6prxQU0VGdb z`*d@t4_cUuuybjEF7pEOVq>pH7j$)wtmF(U*FY(Q?RPD_U6wTdr1MFzx@a~?XSFYW zK)NQBM=cpJthKzXbWj#!Z(!B=ci>4%9`7&)zg<*2Gh*cT@7|B)U%$Shi+InZ+h{Q% z+wcxqUzV+_t1ElR@P7(WlpI`MtExH)?z)3;MS=o$=19EaJR4xjFtge*`cQwX^-R;@dZ6W7&OiD>XMwGRfYnAFmwBN?|o3Cc{ zUkP`ZbTR}Ugm;czC|b2>a1>C5oFj+Rkg}Ul5(0##Z^xSaK}OAl_PI7l7}RNRNfF>* z;o$A`UL;hUORJ{bXmUGgh)*{F49>Fh}(%wUN+)E$g#r zW%#q8b+A$l=c|Lz#!us}_%Zo-wV@@5F+&SQDf?PB8~~OwtH}lT$Hj| zv)c`Q40!9Ns~M-_dENY7htZ(qyo*C2Ky)1H?S1`i@`x;6VImK18o`7L5(h^1NzW$sBc%?`<@BV`?NJ(2jK)vWS0S3MKsZbCi2_ z@aCeE9>H@vyL|{F`WZx?(+A}UKy83L5R&j7sm!v(6Kbai4F`a-XAAQOzz5Rlyl{qh zo?HJJXI=vRT3^eXcXwQzoUUKH7P8fRFYl(yrPHDG4k60BL*z-_3(a%w9HD4a11Htv z?EcJWjn6&@QPVD^yEVDdCHa6zV*KM-ZQ|tZUoSp=J`TFF-!jL^V#Pmea zrgeyf@Y%tsr8c=&HvH&5J^k7)Pof71c_N(ikTZRPA{OJ&&sa98xL%oQ!#OfNJq>bI zgb;9PK(SShz*%;PGD)o+$ZwXd@T#f|F}*&&zOq7_9U7%Jpo`dt?lUY?is@oF%unlP z{rB%ehSWiixvu6^5SX>($6KOybM&JH#eLD%`m$6Vh9lR#ns?imPm0Vf84 zsYvhO58`=T7Kb*=O^6@$i4gtY^gaH>)lY`n+Kgp>-9KUP-|AfshG3_^us2IMju0>| zY#QV~plU*dSkvlXHk_vl2hED!;T40ENn9KS0U~vnf9GIhlarMtDk;uywR5df8?aEY zdxud_4RaaHeN-loS_&1rC6{g6jc?Q1t&IH~T+f-lnJ*r;RYY1r(HklXLK+sZ;=r!} zC>itVmuacC{RbcL5>Py($dV4VwyXOuaZh@1!yD2&gd}ji$X8q&OD_vi%TFc^xm)E| z_LDzUyO;Uvauzl=m$Rd7SchbK{D^k|CV&yo@>Dzar*h%L&i~RVty-d~4jD=_)5zHV zwEdH8c)hLOv_3b+>DQzYx8!>43z3&S=hjS!IBuueewIWKDNzWL^4o{UfrYO>L!zH^ zsw0Kf<^{+c;H*GKwAEAf#IeXPD1#hqwh3)@3aTlHNiML)T1l+rS=clVug-ZeuU%#R)`ele9#?EE)zHh3ET5 z}G!VXpP(Rt|1A!adHFnuv(YgO6LPGUE65@Xw zB9T>aFoaX2LPCVid$?N(@gM`uz2w1UeC}qGKb|0tta^gU1CK<{pEstE*cSgSLy^ZO zINx5DZgJ(+UA)DbR;1nU^S#b9jcCoUw<$-kbJs}amdnAP0{71T>5-Zg*S)ns9$ch0p&TVD8&A_!KO{rM>P>#~X2bqep? zJh};poH*rYYUzaWIvX{&=8UUIj^rcZuMW06d3?F}G&Wu1-?ZiHzq=3AlLwzTLfQtk zRE(%Vxt?AvMA+vj=P7Bu1{RtYwG0nBPR5If z$18MNKlsQUZ%T~N*CJQCbihaKF?xz)9N=;J<4}#$@-FH%ouVk~gZ=HSU?6UDHmrss zqV2N!8KEa4=XXvkM%@5M=U2fS2sr$|Iw-*U7DGNi+1D-p6wUWDkQAST114$*Rk8k^ z$$?Yf_fH~+aGT9P>qT&}w(g&E2>~$)9$@+h#jB+d^CQ5AOq{u>cn#)1hSKTpTC_OS zkL{6WvLGN$=D*2$USPA|H-h>fJ7|nhSvhksPoS1v&NAaOp(21otsh0dMhF=#PMU3} zG*iYUCVH$5F`nr4|AI_PeepNb%{Q0vslxqr-Y6P%+mR#=QiFc{& zYBaBeLpW5B?V)h->MlgM*I0D%#t=j;U$G2?B+nZ0nN3v4Ladl6`1ptsP*y?7(lF#= z*XZoYI^6jZ0}sxlECL-Jo$!KOjansr3je&ojrHh~Y~`ssH#L51Jn!EnBGM=f4@_;* zrs)hM6^7autPuzs8@5=WVO?Odf}*VgI~pi?-)@|6AYkvoGJZNbIwD0N_;jo!m!1vZ z(b=(XOx-eC9_st?%5`ES%dhga(wYs_Cr+{U0!>C1|h4Br@i(bVry?tz1kp8 zhKBNzjN4Y|{HfG!Qu;0)^R1Se*b$nd7bt64o$+kL(;D&v4~^jvfoE*U5%av;dXWY3 z{VJ)W{n_719SK&%oDxNZOt6^dG=0h-g!=)VVy>3iRdUARo&TPk5fS8l#Qs}-lTQm_n7 z++DU0UOn-GA{!oqsA0UC%>NM$lIsZ?qzpgL4wU)p|`eQ9ON}t zx=GVH{BP3*X)Ldz(q9Ewl`dbNlGj~vjM{6M^HZ&Dp=6`@2}SvxB)!!Rme>{U=T+^} z;3L2B4v=2aj?cL+3UxD94Reqvg!B&DsRo;lI9A}w0#Odgx}N-W`}&sD3gpmq9MpDk zM7&BdHtbtX|Mf{YavZWAsj_0DAR0b=Fg`2JX28?qx`7a_i;}n!SB;YNaoAW2=QVY8 z4w!JQq|dCbzCv#2+fW$U>hh^Dx3U_W1+kGN4le)bS^8DvgLXR##q|K|Uw);ZH>GfI z+#wJ}Ztn57v9{R@?l+np#94G=XYeUm>i+yaWb45lNfIFpkRN3`TWt_cAUj_@3^LCn z;GL8@k*515-*Nae@VmMBIs!KQHoB+ikSVgkub3z?{q0YoW>P1Iy#BqolA|K?o1$mXOE^pRr!ED z9VaY6+2dAsYBxc9dq>u*r<>+KfkwABOX;c%WXaCWb(M`b<_H-TO+vOI1v5o5=jK-i z%%juI`Zk(ukp8rv7)Y%Jg<^y!8@zs9v;5s?7YQAq^u-^kd6a0-N^*|iA(411tERwC z9Z7y;ilbb*iuo*Ol3>lK*M)(SBb>-S?n&Hs2|FL|aWML4oc6xU?I*6Y1gQq{_qq2i z?dS)Pz!xF}vqxOr+<@ai`R*4+9bWn|<_%a7Eo<(TG+n;F2u9CQK}fAp!3N;$XfzqY zk|3*t(r=c*C9p7S-F{9|``pdkqA3bm2yljkOTCZ^Ogm{>ed3&}!l9z1l(9kPOcKML z3)xGn-?9}46~R0U#bsjsTlaS!LXk`wymb@3Af`ket1i`+;Q2KNMeUzIQeXIn*Kl3& z3eX`O#PHUm9!TDaypEu#XFOr}6sgD)0TsJ5~XS#qZhd>ZlE5~%I zwoU{nL_u^XC_o03D>SO@2RIRd?DyAzo*rZ_oMPUtM|=mJrk3eGQKkFAnJNN!y!9=m zn>IGPNl8f%iU%&In$uy8lpO2IIBT-O?f9Ud(mc=2%wV^(c>ZN9n|o~Gn?-S7TtC`W zr^}B8{go`5O+hv@=WwT6i2LprJE)<*hoV7h4 zxGRjLf8A+zHu~|Ejh^=mLg`_i?E(0kfp(m#wDH}!eD1WlF;>ltzd7i$eYN|sdMf*T z#GSW4crV8GX5TevwjitngATW!mP}mf?H*p>l{ZK)@@HMJ`*5>UY{n?a=NJ}$ln-v4C?VelSQYH83q+? zBEQR*IEYaI*M)_MI(SmxkQNbnX{v_acK2r_ipZ9Hjc}@_z-?Tq_lRo-PjN(H@X$dy7Q;@8ozVH zW}Es8O$9||`kRQi8|QWUBHiV^IlnHt4iqXj)_8xuBl^NBzxx2*((u3ls1RkV_0P_6 zO{<{9*<4&afb=tYL#dZiLHAIv4WQ9^x^eGeb? zB;RBea<|efMuJSpU_JOa4d8vSR#~dJ!=a?Nb?^dDc1Y-lYjIKfk6M>~Dy`zH$Hll8 zEKcnhCQ9;*BpBmK!6f}3F-SMh7I)F+_I(j$nynB`bJ ze$}~~#dt%1a~+dhAT4;l0j1eCEVw)v$@mR^vW(~k6$4FDzfi;jCiF7fk!o+Q-`7u< zds87u=6n4eF{C7rm{hnF2!B#fS|1WXnGSxSLr6^A@ZQdvkJA_O&UeoIc@c%hWwui! zEjpbf4PRJ^yOiYj&p}NFBroL8BCtldWPZDuzpHPQ9D-Qc1?%Gi+Ir-7Ae!GVI==A~ zqz_6;cxX5d$oJxgB;i0m`A)w(`PNC5mkzvx@qF|?B+HTKk4U9wX>MFOTrxNr(9YS&&v|h+z3dmi$A$XXIF}m17ZEp| z!t(OX(U(?D1hwd5l-R{CH;;-|`hWiP*}+Ml<&*OU0h&=wcgh{`!%%rb!U!b8Yz3Qo zAg)dZc6$VO9-lB^YB6nC7X%N7LS5ML8B~7gcm>lfu=1!x#X6avHLPp-`nvis>T|~A zWf;6<(yEP%`SMy(JDq9cFwqY(KG4R!eMpk zfwCO3nBP#6%3K4Y5Mcin2CZd>?cskmSong^*`}%QD722KQrj9bSBoajC_*z3VYgl` z)jh;9In3#)BiixJ@{~2_4Z*}4(N{dg<@NTN1zlAQ^HJUy{|UzWo5)79!zmM+(K2Ik zX-%iSWm$th+YeUdkpFa+<@ik0#DuP&x>{?>-K!a9x^%gLJWPQ1E;hE#IQfs{;S3Fn zrQC+Ei4OP+^Fl8vb4g2|XUp;0s!3Ru0Qxbdd}d)y=p1spCC?L!mC2Z-5#f? zwf*_)oum1^l9eyR22w&oLV9|X@slME!EO39{#8~b8ABX2g~^E_9GzB4d734o^QVxt z0h5JN`-7T{e|bIRl>Y&wGAU_qPAJ>bRws28)W1)Ha@e7+=bjuhTH~C{gJ6DK>?-&tj}R# zyKObns1IBP5?7wCRcyrQ{rgt7lC+$Yr#T;#^DEIRuC^=O3-0hcIy2RtLb7jD`B5mY zU3O|&%JYQG5}FJztaLds!Pejn@{Uf1P6Bo#taz0QqX+TJL#44BY7TLgiQS6X3O_fs z>aEU$1uv7XuFc|!XVvop$?EZL)enk-n@sI(p8k=M`2BL@y%Ur$ujOEzt*3V-U6A>F zzg%l@b@aIY+v|-#{MCka{Qb$M_r1J)ed`6z?RY`?KMLWugkJ0SUTz2$URL5Kz)dyn#givz z$4e$sv%%sX>~(Pte@1ys4;VU33zZ2oF2h#pZ{0#Qd$&PUa*9@ZI%ueM`}nzD(DO@7 zIsQ|5ayR+Sf)2&1Gdll{1@^>4??UNIUN?u+pbdG=7}nE?-@hHY1mqoPs_z+8Un;5n z$~=62N$Yc)gQV^Y7Ir_sum?dK84Lf&<0pRCf%Uem-?e}G)bk>iFq#mIMM>0b+gn=% zmragy%)EP}-#RwpB1U$1|D?n{S&bCBe?^4@6M^<%iuvLKPB7U}*+=P**e0W9K0bYu zQOCOvjPYK@oR-J#K1`(W!6g5)v_u`&CY6>@0fTf=ay_wj+B*vL^bFPo3lp(+4h20n ze^*mKc#y0g@WoZ29@6uB>iF@QHPa$po%{NONxXXFtiQk?&e6v$ zgBh=o_wV1k-8^8alq8EZ)7E^@VjMLm6wBt&9pf?diG+m}jC)_d(lPP}8rwfZb-J85 z3?x@qHJF=Oc*`xX8N~{~)icq9iS@Mkv?JMVa07$08p5gpw$jc>HEDIrog(@v$a2xx zj6uw4ao>wUt%T*p#H``5AX^dz_k#4J*2R)tm z*d`nSCc{+Wz!S@UkG+luAACnl*Y3>DZIpn)?1S%}pMm}X{-63}t3G}D1dc&gjGDu4 z7wzN>!+G1~Qsc-m9@E0rr3qXf564-M`1K3^@(KYKsUM9h=^Hb|jwHID8IPLwLn;mG z1Bc>GhD-Cx3uK(c7*W}K{F|RY3<;s$MfVQCV>;Q%VO|lw`?C2Zy;A;T&2=Egt{{mu z{`rq844PZV@1>VKs_e#;p0&cL)-RJPCN(O3BWaa3i=6ZK6+Zd!-0=$OS#&#?iNj=L zWn*P!`y?2DeqfeSJm_3TK3NfJWgOOjakeJ?OjKWg0n+Vm;~kkaDBCP{7wyz1!Be-% zvE^7JEqF07)#LGdN8c+1AmPL>7kY)C>gz8JcLj)f^|Cllb#7OmSsb05FliLqj;{5? zuq(gp4yV3LDsLYj_w&HtQf&uxKcFynQfGBTJks%N_@LNgK1_LwK8D2MT4wU8-4|v>?=vi60XHVOCaGqE8eR$Xb zRG@p`n_xcnh_`d0>)n3**Xo_AVx<%C<$w zF(8GV)FyN4fCK9p=+wr87cWXq61`Z^Zj>OsYWfUM(S^q4=3aKcxmo$f+}v)L^3>V4 zdFl+Fx;ns+887vZBQfg2MdLV0wSSQK!rzURnTeCc8a)oPGP@iRygb@Xr)3_rEafEA zyyLq&b4u=h+#D(4fFC1DNyO&d`Q@^V@)1=pN{z~o1GjJbw_OD;H&iVPw5lhM)?nMr ztmCy?TgaS*uUTn>YTposc^E$`Wwr>?`b96m9 zIao98(rdd@YrpRJjMR4jxAKoOEY)9FmpA!r=Uh)nA2~IVY_JCgwdW6PAJ=?*Bsfg* z>Velc)3G3n?T6TYnsGZi{6lY;%kT|OFO<|c-+mY3)t;%Wc7NNKPny%hiHd$}6_WeE zbg5BE#=SVi*V)?~_89b6-0O_M8=C%Q+Rs#OGxTP#r@CXNb%BbPhvP}as|Y(rajG!C z=Gn!WF5;v|LO{XA^EteFCH~@}>|2?)B?eu~hlbrTVqD^mf1;NJ94}FP^F3iUcxYId zyppD{Yp|3;@`uQ;cXvl;bUNc+?1tWt&$c2-dUQq zg`M$n^=OHo_*>_$=MNt~j5;{9H*A@@ecr)C$xX@7X)AxNgJ|)!#q`F|oEPU7oG;zH zerehln3EN+7Q5#a!qrq>q*6G{zs{HWweG8BMmQ&)*}t%`Y97<)D*#a!8>?b zPK5dZ-Fp_AOUO)Twd8KQn23&o5irA3d&4$G^GLSLo#Zlsv2ewfRbbeZVpd5_}}s?vBY zSx+0XK-ST;nn^$l4gGOZ_l}Kp>{?DfAl*aMg$6Q8ilU1ywN)qo1Es3|$!N zh9iG>uZXun3#69;;YIXW=R8bSf6NnGAwd;;dF;wN0Z0NsU_3fteL{54uSvUY;|%U5 z&-ifgT30_?g6Rh%J{wK_W2&ksMg;IexPde>vUCx<02AQvc7NsOru%o>l;Sxec-1&*nup{JNePZV?&;~R@#yB$QAG`c4CQD12(EWy zJot@Y1n6!HLG<=%S9$+bR6=B=!}jdUssic>eF*Yb_Gp6*rO?3=XD)KA?gqSGS=1Tp z%4F=xMG1Cg@o`P?QM?L&3(vdSY5Q((_7o!AXQJSH=gOUUyq)dW$p_5#i8m`uy8-7x zkHGH#$J|>+RTZ`E!YE294WdZ6Q9!z-!wpDxw}g_?wP^(uknZkHcQ+`dbayG;-F4>1 z=Y8LE{(OJF@tyIl;Sa`k@3rPybFO>daoyKdu!wkGEqKcHvWZrn*xHTg3K;CPh#Rwb zmR$20P`r={+FSwtHcrbqc^VY}ox#M$-kr|20)@EsF!tx~F&E@P&)zf$33#ptZ{3%o zkE^P74Gp;@2lnKPkXlq09D|pMO@U|;;;3N9yH~cOs;*xBoXzG(Y(ajOTmaMjimb z46S>(Ujn3w#w`sP$pn!YqZ&6aHl_$w#0TBqATwqPEJ*nw6z%c=8IeD1yf{f8_AEHd zklPg)^F-X^$*8)Sa@X?mVhV{T^(9tHzh09C47B@&kt1#mY0Qk?T^VfaJ4W*m9~1U? zz@yL2%UjTmefPil?9oD$=`bJQg(q2(&(77xK~$Hh0r$V7JpHxP`T_iFFDVyhA1~s| zg{AD*7#J8+C62fAA!7$Euhv)A`g%U#GCe-*`pj5J1wTxbBKp&5=1Ev@eu*p zOi@v8UZTp0=zrrmF`<`fzPomdFIV|H(L%u3r-lzbb88ZzNSmjB{kjIW`4NDoMCj4c z(M>@KJjVP@{E!Jd$O!o0V0~L$UzaF8wCz|1;SK1d0PukhA8=OF9QU*PwU8Xm^FiiJwpGc9-XB?XnvhW`>8^xL(&3@nV6;CA-uk zv~1n=$Fx}Hr4QgKyy_(P==s%i_NSBlWOu%}SsK90vKx6AER{*VwX*06cZEf&Wj9Kq z?H?cWJN#7!J*-zuE1+1GaASX*lJDAflse2B*VBcL6HF>tWw0C&`I6?s>caUlkd*ht zcU8B9xjo?+JhGJWdAI9dgD+9cJ^r*4#oDjHrO85^uG;q1vYMdHWJj$z=Y=QZ=APYBXUkIKp;<#K|TZHsavOeUznhJ0-c$kJ=4Y@c?rkdu^UtqFPM z0}|W4&gLE2hXU{v&IBa|g;Ll49U?tgSnb1CKGxh0)AgXM0ejW$>?(2^<^LEnv`~N zQ3M>(wZ**YkHy=<(}cectydheM|3kMNb$!3fmw?NAzfWEQaBFOpsw>NQ>tR_il@Ns z@W#0LBEYjwD%Blm{SAqEV3~YsQjGbmW3|EYUQQEb3V6w&)fX?-8r@1i`_TP))48?hKaT4OOQJ-(FFRZ2-{Bdme z27Z8koo8-&qh?N96F(aGz~TeNSCnq%j5pO5gG0_-7)i36#yg3(Z?46fNpN`Ix9#EH z+}wZ;NE$K4ldV}{GztDV9`GXA-M!rOKLUh&-#GQnP*d~$o{cDGz9hyy#U zPbfe!V3WUT5CwEwW6VZ-q3cT70Fyetc(VvPwOLOyKt#Sj^qmPE1O*>{#`+jj2_Ay@ zYQ2NoPB3m-pw1l#bkP@=c*IE%Cx}q*tj-!+s?R5%J7&u!J&q<3u;(Jp0s2ju@a)0r z+6xdXmG6EWUjN~ZqLuOu_-Eka!|O!Z4QFG4)v*zHb54LW76} zlL3_&djN*P99yViZK)9K0sXVRy&NVSP8^%G{J6B+ASbvev*uJ{5Gcg85b%bw~pL|D$DrOw|n!z*!}k%;q`R+gBW$%kn0?J5>fiCMCT% zGi$~8H>MZAJ5vVML39dO1V>*UIvBW4=FAgomnsh+NIm2_ey$!0-mDp3gZKI8AM0fI zl}W(yI>aRNgsd~_GXSj?>C_Fa9}@LHcXPQqv$>8z^yj@M04ppB=5op&SPYb!s^>=& z%Rus&nug`~i*SE(LP7Tpd!Fm*fO&hK9%b-8x9D1+D+9GvSj4UqpuPF4gui~|d3)XY z*x|z#z_8{!p-uuc-XO9HfQ`nPi!5z!^4fJs^E6uBHWip_JW(}~`dHAmxF5Uv15|%9 z#A=4B%}*J5!eV39v%d(#xCwI=8#JKyL!?tJfC^k<0h*8%_GY`N!d6|Vi3Q1QdP zmEz_T9!MtGi*p6jX6rq7fq0=0j=#Er_H2cFr(@lrgvs!LL{^Tg&BFu&U;NWz#-(9zNI!3vs!+Y#|3MSY+t zc>r!|)j%rM;4L6Kh^@HslQ>ZXWhJWZ$4PIv{cwa|6JP;gv24Qg@*GH|#bEfegEP?V zdO;4Fjl*RD;Sn6<+Z;4Mg^=;bcJ47X9t;5}j_u>aoz+Qx>f;u^?wH?3APEa99MnF*=&(k3v~>^*I9I*9 z^|&6tYW(;^?NMypYefEY^XT9pp4kIx05t%Zv+WY(H|32MvD)5JF&I->3<4-~qJ|QC5ekx- zdCIhD^1tkN^`V=8)yGskU(8Z`gz6LC@GaqB-oi z5&=;=f)s2efo*rTIVLR324M4nm8=GoYB&eL@c^NPJIH=HBz+%1xMWo03q$a~KYbrb zM;=U7jO)VA2^ar7sB_pp`6cAbRq*)1!-vN^v%G+=1*r6FjOLT_I%bMVfUSXrg@gYK zC`tlHq`CO)8W=PX2AfQ#Q}WwT4{in*%QahFpA=4ORa&TI( zt1mB*@86HD_?@PCtkbM}D9_DGF|@;-o6_WdF@*f9%czXaxDRxg@w=W3kh2I6OfgoN zu9c*<&Uv0*H`brZKGkDu*#ylIaZ}IV>D0GP{5&bsx5Q-Bp{=r*hEk(o6JK4J7i&E| zDv&K;LLPfh1>twPNz`VX2eS$>0(}E>x^q3O$Oh&Z)}Ul2s_;z_3l|CmJrFp>1}ORb zRM_Ose7#=C-N5r}gi_N5$KQVMr0*5b71Y($?dh565t5^#iIU9P{br-5YrKDSBrRDG zH+}^S6;NaW4{(xh@pbJ&~;B6|k|Wd@M$ z2OcklPvBFQpGImoh$MZ#^pr+4Rx={)F=i+QMx6BJ^EPkQu>Y@L;)`Szjx28KG7+ai zx0dt_3>?)HePXF62EOXxzmutkrk}Mfb!nZ%P5Cc6^%umFUXxsqw z7+uJrBGl+|GNkv8{z+#@0JRTbrDc7#GhUri>wxu~HBh!dr+Vxs&C4g>5xYXk1Fic} zqvom!Zi4*$?z0yT_7`9ig7GE4tPh5R07_yA@6TE?H)j~e`E6ayV{hPXL}}Lz@?~7b z{{5<$d*&c<59loJ{3##?i3S0iZ=65XGj9~kbo@s|44L>~5Ny?bAa}hc0!Cdu?{3aW zT>Iap)Z<@ozzwrMt$IK^Pk;4syRH(2^9jfnxo&P(uT>Nm@7A>0U`^G5GC+O#Jbc)* z@W%X4^&?Eph8a*+>Cj>Wiq>z@_74tR4v#rWBk{k2j7N$d+sKcXmq8?MIZJrFv0aZa%t zL|?)5OS-!V9<_t4{4*weM7>VA6x8eJ1J!ZJ$Zjr<;&rs@Ew{mm*JN)W2c(~nPu$Kt z>IYQRvK}AHJX!_oCz=c|BV)39Je(X)W-Ts@KxQcw*I+P557bOF4*TOl2nW~<)Kpa9 z@NAY|TQtaS^JUZiuqrR{ZQI!+A|{K8n$I|8Ac03(?-(fa^f}NTQ_W>7HnN;*kjO27 zHSmMzc`VS)>?IJbbDOYs(azc>^7I5sc{#sw!UOs$%l``tFcc0l{LI`9qa~2mC$}ZB zFmBtnUoKWI+Cw!Uw{RqU+TVQU24)Qa-O;R`$fD7_em&vzeja#08EsS{Qg4z~Lt6hx z!}Pm=W+ED7(y2TmJmRldyO_wkI|BNpt;zat+~H$j9~`m5#MGnRaG8^llor=+bm4F> zZdl5uyuCTKval!s9mu-6bKqq~@s1^Y@`N@@(i`jaP4qpwXCpze7F?_A>)d7|IsXDP zR%gFuho*xAI#3R^@4LNO-Q{;Z1#%u0z=`T#B_bf$nyg+0`n^Zta&j(ppPsw+J+uc5 z#Bt>xhfMz!>7rvm?f|J9IGk>8cFRF7AT}&6EG$m{Y8`AfY(YFEKAns17yH>a9yi_5 zOfP+wmX@w6j;Or>1nOEa;dF8UB1{^O-vQW)7GxM9GF z<5XYBf2h|R$J^QE*A6VtE(aG}d1SpIVQF`_`RuZNI7_bH>6Q$#1iU*CDv#>DFtt#$ zu-Gb?EMJQO)>69PIUZr1nwpvr#HXlKJAiJtR@~~Yu4Q2R-oPDrQ-eQ6L7Noi>|8O> zSply{$M?qbj)dZ-t2}qHoZ;fev}_ziK31y)zODu$(@$Del)*CW&SlT11yA|~Z`@3~ zq%cEK1yXKL3aZD8b*vJDz+0uHmsIC)IEL~aWGVO!T!L z&VzKw$pvsK&sXxTJTwpRhh_BU!DbhITXxm`P*3tzmhr`+H5Yl)?VlJux8L>-ss1AA zEE$~^=T=gR26+7O5gh;W0Dq)JlltnpK}mqKV4fMoe<+YP?IbSiJHMsuBm}x*bxNG@FJr2cQ&RM< zFP8D4&V0Jc%8`wAM-#elEVPA%qcQ0EFJDT$3I}zgi5fhuE-yQni_4oO-=1aLq+<)P zl3K38N+OzA1E~Q3ltz;PCCh=CyNrL!@(XzzRNLCCRXvZa;f%$1yqs)cWT2v9s58uz zgP$Y|(h3_tt%yGAbOTp|jJ-xFTeh!{0Pz*CLY$nMJWw?E04*ONT@BJSN4$8YMq@o1 zeZ6~?=$ws9p-oiRcIChwi!uy4KBinAaVXr@uC$U zgzw<^zg(F3e<+ol_4OeJ6hD1s@c&*r(R5oR3itGL*> zVzjo7mKI-$5fkjCi9wm0lU2cEyo%iNhL3Tdpx&|O%UvzW3kRAvnwcJ;nDOM{jGvbn zA7#DiHlhu!sU5*@sWbFGyDp|6;1s@Mm~Jh`B#4m3VSDIgdrJ}tB}f~ z@Y-Kf>RgiBExEkh;Gvp%9>QOJ&jybt=*EQ$8D@@!8gRqyeJ4NP6|;mE72y<=glm!a zgJOcgAB@Z{5AJ5wM1(++B3~r%zK~Ir#crzazfnB$4UeS3fg_E)KYx#RH%F-oIf@X; zd(6sN@2#FQ-<8Z3ZX(Sp&h9hIsQw;mI==f>ORs5#pCS@{n2I8pNH+Zkch94S`x#X- zYKgcQ?z2nccNBLuH7C`t8l?9MQpZY)@QLn5Vy&e@`*Y=O5R$rR+N**M^^m)v%tWY) zb!nXVJoN%BUXiM+The@9|M`yw3>_t;jdx6@B6w#2>(#FaR2r&Nr+LAU;W1sJ7i$m!j3g}(M z&k~Gf>sb|Ai>RqXU$OGPX|RMVu3u6AK(cIEkHPzQKJrns^tZbNNUj4WNqkrTxgm0u zJ`a?~3$lr>Zz2E2kv3ZT;RY4yRKC#hV+hl~i&BB-JduRYMAtN2^%(2}9a-0_4$3l@ zpEDszwc5dUhlX$^BCFM)P z<&Pev1yJ2wf<4%cz|gM}udKHMeSK;fhn*|Rg+*D%LO)Z30i$2-dFfDTIVV{ESXM&tYuj{eWC_I@~fNgC5Fc|AxY z{*vB~dPrDlL-KfO_<71;L3`Ct)|_sUCnz1y?}rCmyTg|WM_FtCz7<2aJ?p9Lq8+_$ z#9KYO!4oXx)_#?rI_$~l+}Ek}1)R&5Vvo^_o5BgMJ%1P{6tu(7ymiM2C1wSsw)Z%aIuS!^+KY6VZ-;iF`1;XjYZ@{O6kjcUd*JLUS=c4r==3&sXz9tXNLm?Zd_lVEWm@7- z4grd}^t7}f6^-ZLHZLed8>M;Pp8hxe;DEJIb#r-rd6)bJf#$Tn99(gEc~&h~88lca z=6v|3W4D<*9J3>jo#%Ytoj)aDQy^WB{rVuu>b4$M?T7QzUYFX3?|M!$%}Wj~ zj+dj!aFcn(J+8rQI_Q8UEQc{WYg;Ga^jQL`wA%7>37eVWbCb<{9MLn`BwJ^%Wy(fnko;q>{T zF-LqnU2ylZsF+v?S<*lreEl1fPblb?zeYVF1AVat(j5&nIB1|_fRD$+!r8gz$H8vn zsiOvr8`9Rld7ZmB4k{sCXPaLc-&|ty9oa_vX5K z|H{{{r#J<4twN!Hy(T`Z?o4et%>mC8;DO(K;xX={9xwXvY3gbhv=PKU1WPGAq7wk= zB+wLcm0eG^c$`f1%vx8IW&o?4UZLX)EuZY3VUE1fs z?(n>!cyt4tj?3|6Wf;9N`RQPk$M8i@Y(xapR3I53iZwQ!5%U$&9lD_oRaPpBQ7j&|lQbKla_-09iA-OcZLoi^f z$7EXHX|_9CI3K{Vp(Y!d7Z0^<&beBxq~Q(G9lMQ8&xs(igzNO(>_sWeX2=jM)a`tc zRI$^P>L$G17VKfqSU&XjDQC5nV&2)&(8wG3ZRvfOHJ4y=`*U*>*8P#zs^=`MeUn zutapT8XC5a;*^?iF22j2%44dTn*PpHp#y19A0H9(6Ir<85M5-{?~|QbfE6e9eh5ki zp6AQdz=nV-2%>yb&+T+92!Flt7ybx@Qvz4-Wlh$Eu{G>@W-pF!w`Qw5oihM2FuSz76C(k+c?;)s#-Y4}FoS2$@CZ<#{% z1Og@A7&BMf!0>a~@gmM#L(>xMVW2C79jlCJ|aOWdkG zB7ZL0xHo1>YoQ0EtA3K0bxL1BheyS+WNYT$oMnHy=uByaNV{f&-9__kr*78N)3UJf ze8r~LV@~n<4XL>k{1dNRm@MiAjMafRN~Mdg`rub<-^a1J;>R140lC9D!>R@PhTJr? zh40e|az%1d@Pkw@&&zbAIUcn}>&WMFTqs~b+Sa{iCv`IN)HSg#Bv0e@wU{ab4sdzP4+1*%-Gc>ZV-m#6vq6anl52V|+QFfm(C^*brs!{-&IL-3Ka3`xK*cus2A%zS=jt_&wOt$hkyFD zbaRp!#uK(5Tirr!_3HEj6yX{Nr)jOmMT48#)x0{*4r6opYD27LaTzS`e_Q5~GMu%G zw9{s4R=1ij`-aG`>-J6N8ABu}p>%A9eObt>j{=tMmXEuY8k8f;8I@0@rO(`>6`2F?Zsjsr#Ms4z$` zV~Z(2)^fy!%IeniW?njdQrIn2|5$8$U}3O}>pl&G`Kg#~4<2m_7V|<;-qi<#mIIGg9u>%9l0qGPkGXjZHUz?5ms?l$KcX4sCVL$bqi0S6!lGPXb*3R7`#BJvSpkznE@&qcHnjnBrNxqV<-eG}$mQTx>0gZ^! zg$Zj+y3HO8OuHTtR2&@nM2v}o&L)CME7n{R{=b0`QkW!vh32P=$qKM!2e*4|?d;y% z)c^6srygH_F>?98tt$l$xWUkIa)(WCADgs`r0qQV%lcu`U>Kj}ttEp%oWNJuo2t!U z%jrWHcAIfe$q%&^$uB&@I+uq?v18@ff;R5;(B$WI)!3xSy2AYQi04z*`I+zz^xRpa zZx`M!oab{(PctM1Yc}&HY5aC&3I-H94|PfxDGB*wMqJfXs*c1|MmI9n#|wJ`xit$d z#UwxBXRYI-29gcDr6<6F^1;(VyrGx;{zUdVCCNCJ_6t$?<*UaVBAyDbmZ%?Zw2`n! zY1-{6%b;e?C8fto=HX+Hk1ErRt`;o~tcN1)s^UV9=K?-B(olnqL=gw zd?>${mnrbQfDFHN&fnoC#TcVkudDP15YDE!nBfh(>b@qBy_jcn z^XRpbdbjc+s_tY}IYorxd@Vyx*8{c@%VLO+hX)7+jT(9;d@`w%ox_`OS8ZVcvN5>( z)8@LPvTkShj5?Ww#f@NAU*|Q>$@Z|-NRIRMLtSSN$=x}3-}?}V2<`Dx-Z!~22~|DW zpk8D!B#k7D7RRjrcWn^;$PC7(8xot$Hka70T?=d&Jq=Cn@Y|eum<>d>wha+yB*el_A)&pe6qz`WBlCv;kzS_Yx}C4ioZR}@n+a`so|sr)Z8iRY;q$%E!YJ4rXR1a<%C|SjYZbED{0sybF05ZnFv3#XWn^n~0 zxSFtBdI~@RLekF3vp|N2%XTwb$@?%K)twvl#H5`*;wIJiIb^A4nOC|_Z@klDCV=~d zmioRx|5@)`lKJ$|Ob<=jBIDDFFgwAZm$H!Y;_umA}<~3sUctz6-&O8V6)=r#`HM0la^em>LDx)vQ-?xki2#nMWIxiSr zGtlOnpDbS-Pjw{%$9w%$O25s1_eW-N{Cd9O!JTcw`7e(J`YPwTIHPDh5~GKN-X|1< zlM&q_8<-E=IExuWP>ydR#ujTM-{sebM!D{E&1!MCQ+@!{DYQw!6h2P_%S7w-l>0J~ zula(AaQ0vqQM7Nmm~v;eLz`oKDKXd7zHaf^PA+L=h>cVFm{itb_zsOrLDBsEgK;p< z?3Jp4#~4I9O>)uWxD@StPdiM;-g&Lm0mVwk8IxE zMQ2vOh#I>dytPg9aflU`M2sWl1ma7e`#_R=6q>&s@g^ z&_CAcfSa7b%eGO@8)$5N212LO1~&(Xs2j}vvwHt4d?UgIF5U-WBUHqod&Xj;K#+jK}%|DTD=!G)Kk zysnG+^QX)SAgtV);9+kW_uhGT8=_lmX*?+Sc&3iMCEvU~M%Jn$VPdCnO~^O>S1p|r zYFmY5e-3GEjaAH4v|5!#`(v?ad?nO@d-V%nxf0(E%D{_cz8*ai)|^^T@jRFC#OnY> zaIgapZbw#G&RTT3m4&G+3*UAXw!T)YN+eR#w>#XpKtS^?fA(t;Z|$ncD;_?kzx43; zt?lUlIQL-%GtlhG*-j*`4-QkvlzevPj7S76)bFO?JLcYirUG2^;8cHQev@v|aM56T z)9JnhNTWFL&M#TzSezu6mE3 zGMg;4Mz9e-Ld!ArRaa3t?#v?uNRMX!2vkhsbV1Z#%Th7G!O`EnD)Y-W-gZz>{peDG zbe+rG*eJuf#hyF2+U|?!CMc1}exom)9}5x+>S)TWmMbjy8xHOR34H81C-aI;agM5! zr{+06}1WhLsxyL*b<1`a*sL&h>tiLp+N6vc(MKh@pfRAb>4IB6y7X@Bd$dwWw^ zNWZ3!TyR_D)&G2;k&@r|Z|?VMurWjOqR7*247^U6H)6&HFNDoqjOb)CL*Y zx_I7BJ=d=DY>3ipuwTE=ABPUfblPna{`$Dwb7g{otHyEXg8_@y2(<6|fB12`VHn!^hhyyidD>U&cJvJYlQgnf*L6rFzq2BEGH3u0O};c#`!y z4|eU(%TvWlax%WNLpUNUH%B!jvMaZ^K>!uc|21{nX%ufl2D66v`v=Jzd&PLM5mH9EgO63a=XK@E>fx3XsbjEJTdVUMr;|V@uI4< z(X{Mxn^8oGuuFMrg@vx&`)$M=u0}pN!jx^6>c~~frHxv@aqfWAHK(3b|1Hhw3~c^8 zH%hbgN7#vKyP|L8%6EOeGRSngiiLL*z8p2a-cW-uk|4R=;x1B+_K8rzE<{(QP`ARY z`FF>4esvO~8*{Q^YB4q23kzlS1;gI^>-Mr9>rKk7jNML$l1`+(sly~##x35@KRn+* zuM})YIZeGl9-O0Nx5y8Lb3QI@s#1XTyw@|9sl?>9=gH60l(~HtsPzHDwo(y{cH%nl zumSAa6Wp!SN$$B>V&`rJ8t+HE2TQwX^KK>*%^Cs~;c=&jCtx)_^&DvuRT4~z-zMYL zDj$?BOnBDQZMmo#IGLaL)N}uDrE5=U8y{J>-e_7^Qy4(W>LW@K}GS3iC!+t#`* zb~to&124N>kiO3PPvT9&MaKRHDno@HMN8Jt>>xu#LwDkD^ytdir_*q`@L5@Q_prdJ}6=>K)IamruGi#HsQTrr1RzYu-K zs0$B|_TYJWSmxUdo-3k>B_fe8`FspuV?ZNiK}pFA%dCN z*(_2U*bS~{dq3<}ht{(%%_=V@_A}-gFEj<&R#p^jOi8e)Z7lE(4~j?gu}CJw)ZFC> zqQY!ogHH3z?tcvpQBhDHjF~QjL>6NkP0Z5^L>Aot@grxug9Xw2li}0l@$yH4T2yi9{P=(N?ai|sQmO?(>sSC55 zXN`;#wmv>l^4#TWU$)gt!r?8`vmdYe(+j<_J&-~N)x8ri6Cm}WOO_7u*)|T}ZgSc{ zDhuSAB9hHYDzsTnJ3l2WhK0mrmSlcuV#{h|E@D!m+5gp}KH=N;Bk44$20G6}rxL$= z%Tu$o+p6e4puNfa4kYg-gn6x|Jd-_OYxZMl!x|m}ivIHQ>Y6{4{Y)L|FqF!TPBy3h z&bwEJP{Xt>K^?_$K7hqFDgth<sUS1Qg7a$w(fzFe|!cxKVjFfrYp zOcyiIGaH=cpyMY~_}Fhvn6~*TxSot$mBF*flGV!1*(RP7 zs1uH$l36zq%NV3TwkclGh=~41Nmbx^gaCY>gojlIstE5XxX%LvZogL)#eTrwfi`gl{ir8s^JG{ods_s;Os@!}nkf*m`7yM(j%2 zU}Ne<)8>=Ujo6pti{o-kyb0X;@S*JLi`$B2Z2zj@WzS*hH7fHX>)M&|tmZ$H~c)3=olDg^G#KJt8?8pX98Tmmn8Zhlkr8ua#iyW4iAqqBK`b+Ol|CY$*sB5smC5J)%j zihi1LJz4Ud9?hG2medaY;T0Rb_|*aZ(Fw<)4r`6^W^;8)mL8TL3-56PgBHv06WfEn zNL;a@qT>08zQCT2KcPjRTJ^+trWLrAc z`rL2Vx7S8e+aPeriHuYe0L(^#wnCab#zQwxy%4HUX1J%9mZ#}nEU&7ouni#;Wj`iD-`va0%`7Mb=~{)t zlM@0?^u1_FG6m8jU~W=#6qgBp5vb5w$=Qe~dFtx$ywBs9a(!5K5MUO7V})(wjNi=T zKC7Ws+LS2mV*HS;y=z{tae;<#-CkMN!_>4E`fB0Z1DDNlNscAPns2NRm(O6{>cAi@ zEw8HlTK2(_lLNZ3!pE1r3CcP;NqV7rkQgcWP)W&0Xxi+?H=XZL1r2p&Weo)goYV`4z`I~8uA?b(-%G{E&A{QiDX;LqIyaQwetS|GN&T094B@|$ zHoif{&HWNiYF*VF3IL@sd2!Q3)?@ui_Sxp}_I`~B{fp5JF(!1RF`P$!_?-l8IeOrF z3jzQWQE}hU^WMa_6zWcY4qB571Po_KnEr(uQF8HpFVQnTYR+tte-~{R_HlGTa$Go) zLKN(fkZK5Xf5jDU+?zoTkxCSxW+*ZQA6j!J8z|=?P559E;r^$1uZWAAnvEgkj_%x7 zEDQOEJYaxHCfZ>BB`_nM>>V%c zO>!RN{JRlPihBlxNW);xC>#UDERgOV0y_pCWHWvNnJXzIvNaFu=;9xM!w^CUQN5S5 z{BpSjrp|YFwE;hIQ4?eoAv8!6SwS#B#XT)Tbng-q`G;C_lW0)FGZK>sk&G8?C#IQ1 z$;}Ym&8dU9=;B^PD{7xR#9OHE=`!N{XOIH`yZdV3`?ic8(v}o0#b;d$690);d=iM8 z?dTTk!ab0vz2Do?(rd%rme7vu7KezH!L>O2L($GjV1z1gTSrwS5@Cv0x_DUqqw$J1 zOSamP;2TJpnliL`uPetU&q4M4UWl_Kmx0<^m;qu_1BVX|#S@%TuMGL{05i#aJ4Z_- zi`GRvxl;4fpU@!0n(%`7hAY$3V4A75Jlu!12`^1SFfi{P)PI9}`ub4491!51S~FoL z!+$I8J@M>Q>HL=f{!^H)Ol)k7)I4v-BZ(TFkfk z%+`$mgEQ*3akM1)hanP5QXo(;_kSnFt|^)SQZr!9-X|TPlX`yLKlusqX@vk2X^S2r z?5zEB)#i+kVgWZe0fqYFAHXQJkBj62_6CxPJHvdR%iER1=pTNo`X7en|L6VacZ(D} zrT^C*@}RLjhYe(Fa)2&ZUQv-MtqN_WX5pxr15elF`CZrk`-hA%t&96WfK~i8!FN$S ze|6fhJ!d}S)qG1X+uE*4SV@3pA;23l(ZzrkX&zoC#M(h(>=+(y?VRrJoYs0_9aM)w zgr-bON7zmYhnKM$8zX@5NG;3f=NAvoE@95%Nl9U1?;TRp<&~F-o3 zN>bvLs=orsC_b{7?|ld~IbvC+53jRp7HYx~!v&q#w1gc&;SG(A&@cDlivCKrCYaGE zv^+_SOwA=FIqJ7EwG45RI7N;kEUB zyRmqzOiN2=XS+)mRr%}@1_l~BMx=O~Y|lSLVSavIULPQpqAw`K#_{(7JEDt=GZ%{* zRVkx94K3sE#+c5Aqtips8Sv!}Lkk*>z(d`g?PsEiV%hcaeS`jZ&&eP-?Pr;U2fUn87wQB#GxaHI>dMgU z#>U2Uig@hiAu4w0h*x+Jqs z-QIh7iX1YpuG~Q$Q%n6#$U5w^`Ws`^6im5xC0p%mQ^Abb+U^dUT3IoTz!CR>#3ib1 zR8w6|l+^dTv3PhLY=zXQw6JsEBR3j10YTdII6Jnx_iZ3nRFr-3@EpNS78RqnHq*n! zCa>^1FSd3y;oCQZz`NCS=6SNiM;363jdbSCpjN9GJOmlaEyjvcM57^~k;t|uuiF)- zm6SY{h-8%Ht_ziPS|6(xJ`W2IRrRX`Og6E^hK}2PbDNuo9_NN1PbQ6%Ct-YS{XKR_ zG-d#|t%dAvaqjvjCDMPypj9QWsv54!`tLMIB0wYu zeAW`ZpWygALRNJ7bT=&Nsy2#e4Pcdc3#{FfRV47Q!Hmvh7IOk4@lEf)5hBO8NI4BqS&Hcxd^@ zya`qhWF`s4oE+w-gcDFU6~f=Uh!#O`2tD+u51wz43MT+BAfdFyTTu;-1J}DHf`lah zdA?|ob1(drZvaMl8J-pp^ohK>jwJi}~wC6ShXa0wa!(jMhT0_TR(ql*$o}1-i8Zl z)B$<+xqzKEt_Pp!qmoJ2kc$vSkufPkaf1-kQVRTZ_E>kGgDs;Z&`FUTy& z%E|&jbgNg=REM1*pF}1|ZoHMgjZIDS4!nnCl1gkP3QtWHszd*EaU@{?Dp%^ox7VjX zT2bXv1ZrJDztitF!Sq2B!01OlP~40V0=m8&HfbnttMoC?A1eSFIb$OuGfzq!Vy~HP zvPjuq6%P#+={AN(y7AM|J(|zS&gXSI^HX>!cFfKZ=UA6iN^*?6l< zy+s2s{YJO*gMqNth%9Mal68AP(XwG?+59+3RKba;wWXYFzr2@B0=)5L(TY3b^!J5RhGSGdta2Bh1Ri6x!6A*JAdazF#H^LP<$5qHDq zFP_pZxJHkWguKU`>1c!A4)&*ynybkrtsF1>L8kpPJUmQ9e-y{>gcUIwiqN0qC1>*b8#%in)_3`&dtB(f(1~L8TY@6L;CIY zzg;H5<2NLYxc78@Hajb8<^20;m18a-AoOEjtb&pfo9hmVDYZ{Im}DS!5e_7;P_S(r zY}SqEia+G5vOj$2Bd_9c+@GGF-Jh)6=*V!R$VbNd@}=QQqDEMDe_nomgu&S0;N(^{ zY%wcHnhG$D*p$yx@$2ZIli-GG031?aP`=W$z4?arPZbG3B&_OUH^Eb_SmW?wetg30 z$af7aGl`Eq6-&>5XI2HP-;ZY0O;1aMe10GvXn)Jn43L;LFnqo1>&xmkM}~WUd3n&U zEaeT5k%%igv=sCq(Jk{F%*-X_m4>D2C@3g;O>VgQc_Pbh_D5j#?}N67Cj?K~hbA=u zbL7*&tkl%P!focBANsMav4fW8oCU4etKfUQP7kf7);h_~ov}myU|~I+e`K$@UNUYZ zWN5c@1^a8}<>{5(>HIf(XSR-<->q%(!>hSW<~NM0_X!D~e0LDI;Jb80@wQnUli?g6 z{449_^Fxqo0gV*}DfQ160opII{C3fROaVx>^{lgHCOyHTrK3CDnHHWYV>9`d|NT*J zG?W!6<%GV3fH^pe2G+-FuIbNHbz$L*2pj5}mCt1d2b;gg_f>?2g-=U&wtj#`lI1Gb zsb&SAF(`fDsVD@52X>SB+yjd7R+s#PgP;5_>fZV->+Xpf1p`pJrAt7hkxspll9EPJ zLTQk0Q0WE{5GiSqmTqZKq`MpG?mENwdEe{&3+H(GgI>xV`?L4#nKf(HI-btFb;XG+ z70HQ&gh`;PdXiO85Iz$eM)5=)CFBYoq}QK6%^Ii&R2==T4`bM!csXBHyrr=H<9+Cb z75Kt>OJjbG-wPq>one6Nkq5nl( z929tyWu76xR~SZre)`n^StdF&fzk>5#^?&dog6c#ZCxvUd_d@+-i?zt3uv*QgKl55 zHoREJn9IynvDrWFulDg|y4q}{Wq>(ZrInt!T%*ahgH4O+dR-w8*;}ck|BKorgD&Ua z(X!xO^i;C&D&*pYmmr*Q4Y_{ij?1hUr825DH8sT`5q?O+iL4Fwv)xyTR+h#rpM1g8 zWPR%Vc&2_SHtxyb&=ADcFhNo?3k!6rC()fA*H@m6$J-JT4tGiAb>*ji{rYCq9)2h? z6(^6m`-p&$FerB^)NLl(`0W+8{#IXIKqdCt^yPVr_nnEjluZc2Rt*sCCZ zp{6!;cIF2B&$g7Td+DX61kA-Gq4&Y?iv}s z3j3k^fRNF&BhtGiim@AwO)ORfylRJVm>aE0AvC=^+8C>p-E|O5I*3Xx*gDxw#us6f z_R1Liv9@+dHjHa80kjhZ$_VPqa{dny^M5D!!n+kt%t02y`r*tsJGG-k{tx^$-AgC3 zV8*d>%IX9e;==+_uM3y%ys?eOU@szy2>uU+SS1z2uk}4>u%5paFntLxiGzUQFBgd= zZM|vOlll31r(yA(#?!Tq`^O3tRJ%WDZ=DaNKRGVKo>8GhtQj z9uV=)P8iSbb6xC5oEPp%%O$3B^YkHLJ$6?NWi{vG@*HBnk;r6oL;$``Ixb?*?xX%# zZf>sP^XL5G4ubYa80~mMr)`+qB-7|bXT7Tj%Dil@C=eg=xWR^k%*-JOVHb*DmZ?~XEM`7j=8hCByE5Z= zGkI;*R?Y0yD}|oIA%9}hBj|B}))P6ix3_%WC5DUpjPor`v1XobjkBL$z??QjEOBuJ zP{cYYHBZtbqoeJ~HYF$RzzwP8PqS1+U~WOW&eGCd_WNyv^1r4%-Gk`dK(w&?>yr?E zuF>En<~8?{hmU1OESv$~%|pR%)X>#6m!c03iJ-$0Y8-%*Gd0=%GI5!Qmu=WSZ3W^{ zl`pQVFM*6j!}Ei*G!Pd;*}k8JcOzB&TKj#&HBqonE2Oz=+YN7E=)Of}OrYA~v|q9z zjK^^X!H1@ASKp<}!OqMq>udW{GcT#;qvxfEOvUmBn|_rrxPxC`ZhKGq1o3D+>_-2< z4{P}z>DwwXJ>1{}wOOu}5xMNY>P60Jdn72(g<_+l={kLF2YYH$U_SK9l8KqwqV-3N zP&q1YfJsc~fQdrMOyHOJ_``LJ=@(Mj9~)upvnuw>dmdSrgA?3re*Q;iueir7?wfTX zX=`hTAQO$VYF%7i3at-hLp<~R-Q6a3c(^|_JnTfyiKx6izbW0(2ZGHD0CN10!lv3B zE+cc7R-$NqNk*nGrtiVSD{4L0Br&P&Jyq-IQ)zBxC9yiX#@X+>SiDno(7?V0Uy*CD zZFs2lE{(&&^53%c;pL?fbrc2{1)Sp(e2C0SE9pB!O5JqYrKTSW$2CXH)*Ub1`i;$3 zJAeQFDr%3is~`(ey|XU|JR`xYW@#yOaR!3XoX`C6Z9s5l;kj@iG>6m>;R^7k4->XV;bQ1I!KvQl49 z#rAYtOWQ2_JPu-xG2D5z-*4e$^1;(6=_;>6rI;9mA zRBy#FW@s1tY-yUljO6L6%t+l5b4CMM%-K|*jIhI)QOHXpyTAFS6R!I`B^P5ydJ4`u zbE%+_@g%#4d~5bsoh1Zvao%~gMji#gE~w<_jxxk)#BuY5p!#sHhC zDcy7+>n}W653hA_&dSKF>-=RP_kXznYj1{g>z@-56Ei+!n9l|27+kxeJM{FKWx2z0 zM_)hMj1}4Xe0Y}N+w#2M^UH{8VOj1-;5paz-q4>4o0<9C@bK^!MG)CaBv0D$H-Mx@ zK9M8p#iw#B?1!Ss&SabAZcC5*U*d?Odvr@l*^K|ZSBN6)d32z2aGDdHP0dRi5lI`h zF=AiK$XT0~HV+~;J45eTktc-+eaX@3r$fJf`C>U;88k@JHq`p&YVxMWO%hd^$TmC= z|7LL1?1FVaf|wzMm^GQp?5Dp!%I*LR8bI|&dv%-?7+6$M(*B2@CL$c&w#CxwjmY>= z8SJ*v9%W@^L|(i~Tzou9rATCDCU7efc+a94b*e8^=|Bo?l$>B`s#u@2Tp| zGv?TN{r(9I-D_)4*hj6*v$Y9^KX|q~`~s8=I{P@c3^%Ge?90o`l{YH6x;+!~^I2{G ztb6O%xK}Es@GO8NFL?r5$YglZvzF}g+pH+b0*=+rh0_@+fTFn0`vzsxD%g>X#XjO? z3m;V+AO9`5*r9Z!y$PPQ-SHwOL%-EdVrwsIix%cSlHcPYHZmMOx2Copt8w3+ZS)a9 zA=*@mT<)yuEIZU#i)Mrz>}2hG)aT{VIqRL-#um{7sI!eG+o9-z?`Wl2kt~US-i&J~ z>%hvIk9>(5d5d3Y2uo~d`7hby2*RJDo-gEnlg$Bmf>MuZtjO+ ztx`%mTd(Ic8x7w}EUuPp(3gX!Lw5Vg0B-hy15?bXVX*ow;#+-JknuG>mGF~OQtGp> zUCAe_Kt%YWU0gNR+RXIQfsEqh-oXR*7(|$4`sO%pmnf0Eo!uTmVU9YlI-HcLI_E+; z%jhl8Ovc?N28U?ptXD4Ops zzKaK7^{sR(CUd{KrFPuk-@o6tva5Cs+1LIp0~Ad1=N=EePakiym0w?-f2|bkvqwO( zypC>ZW3s)m7+UCcy0-*sQS;%Ev}T)ej}B!X@8Y&yRP95#w1zIH@{&4?PO4b9w;-v8 z1r_thwu-oTb5JsVpmx6)ckR8W&ZygVb`E%*4QfX#mWDz3IXP8N0=0u~Jj5xU31txG z8?)V^y>LZFy5$r6f&x^;WbOqaYhP4h`^hMsJ8j;&xcvza@2)i?b?EZn1S-LI@2+Z3 zH>f=S{1Ngv=*VD-KGQzXuX8#)Tr^JBRXBA#99NrPyKvoit>-8%EnOtB>Oaf~4aM+q z>`ZH&%r;16dejxK>mDbbJ&<$ko4U1C=ME_dPDP%6O}MPATNIUFmOq0nc|OFf+8-tx zg&?xb?0dckt7}hAg+Ky1LfJSLgPE*;|)RLzc&fyJ1|=UcKP8X%%j}_ z1)2qc(Pn3a;yvG&put)dz!=tW5wmGl*Fl#dt#wWhh8tw7!g$x~69Mh})DUP5Te(Co zMPlkOoZrn-zV7`cIP?W1dtU~rXT9s1{wO0SM_bUHBoZLIiTeaDoYJ(IF2AR~H$mcc zuT*9@^oZxe=@=BgaM+*8%`wulu&_XOc5o0sVCWXy6AioRC%vpyUjFgM)Su$I=VoTB zV;vnOQBhi2T5weCY;6f;FfnO9e^FgIK7g)}yn9`jrlrUOu5Y^LUrZ-UOAwe$1X?U{p-M&nu!zMEW%n=hr zlH&^X@Dkpu2(zn#ai{yz54DYn^P^SfPFG7do!UTlGBT2cuV3vjmN~kRsp9iXOSzYv zf}f0}q>-e?;~@S)3O5VPZ{8>^~Qc{*q2~twiy}diFknTV3 z;imJg&5~O>rLI+*T9IlX9j9)!E=YGpP1{(`PiRq7w&*`|*c^`;rIIu3dohck9=}Qc zIb6~a>cAx7e%R7vH#>f~e#g2jF(IMm?UWY!pAUdl_5aA6b&Ed~q$HwrU1s6M!IyFn z^gNyxf%6R7beVQkzjSSFZO7NSJ#LV{;EEqu&6Of|-f%pqP`1XsZDD4<7YY)-a=4!4PPIJ(9Iv@_muV$N<7-Zz6`g&@+O%cVm#pI9#)j3C0nhOLTYqKPq zo12IG`^xQpeL+5jzOugJzJ296cZ`q#TWHvO9*!GdiVy!fjpu5xTi%zJJMcrFGy)Uu z+qQ?M(rCwV9Dft|0D}E^ZDt;+Sp47W5q|oqY;|52e1BSZ!|izar7WXT?gY{*v6#Jc z$E}bLwKKvmRrU0)4#reGhRg#ATh)<#s)(o|{BaTA!o3N91`z&A;5a~*sI7&m_tR3w z^GTHL0ibwk^dYSIC@BK&H*kap(_%o#caYl0(rDZ=3%=y5%WKl*L3D~U zVTJ?x)Q_R`*og4VlJ=t4E8M5-ar)PLhUh0CXv6a=m~ghXzvkP1^Hy>r`+TRb**2N- zUeG;&PCnABE#m$@@p^-^dj0v#@)H-L_^Mmt0%V`?cdH}|^$x>H9-;TWSVmAc-E1ig z)3a82N5{j~FJHclYPt)#r`$#7@Am&7>wf7rbLH8|_esK$iUkXZ()hxRayyAockIv3 z`D84vcUn5)2_}ge4Tr`stA9ExQkMEkSt)*g^s!Z3d8e-Xpr4VJ_D9Z?w#S*OkeSA- zS0vG$s>;fET%4hMdzpXYJiERRRE+kFM4aDYkWQ2`u(6Oz%%fg9vQqAebl#r9%_exj zsWbZO4b*XbR%0G(2Tb#A?^&<5jHX7cG>J(LvzbT%HZJN|Ip`eNz!nt`_nY;!0wJ8< z&Psf|!R?ub^fcOCwLi~%0*~pzlXatf8?8wL9}j=IJ6>D=HEGy#L+i{`dihM>M1_q> zPf|Jn?%>w~D!Gp9!*!nE4|qNWw>ilSyYozgxA)n`YCr@PXnw{43Fdh=I06M7R|+%U zzti+>gaTJnmA)Jjq8xN&q^FYru#knczL0w%C->qYzTtJJA=Z>1E<(u1887=Z+S6*h z)Rt;}OLSO-kB^UrX}2W^e=MaW2>Dh8s`+<|(BmB(>b+0ZVKOcXZclN|(qcAx7G1R- zJvQO+Z4k|$C`$HRI6rVmjyP^{CVkp(2Ml|hEo-$5z53C-*dl;ydIay7N zjgDfF@_+ZfzWPDHU`+5|*+?$pcJXN5xMj#{q zMOqBeB#&R-m6NlYG0yH$^b~#TFAyo{zR(d+tY_fEIn|T=~JhhJST5G zZi)!NZ5PxoF?Ve6x(q}HwCHGiN-9yEokcj5|BuVjN>9t~V~lY$(U)9F3hmloX?&rm zB~MB^)5e&+sxX&NGWdN&G1_h~wK-8XQRCio*plIg?h!b|kv2a+4`P;}Jg_y@5Mk0u z4YlT{VJ}V)Gf=6ko1b3@Br7W|os_J}*D2<5k=jYUjVd1DKkrRD$@97$VaW8el^}mZ z9}NkK_KtkR3BK3-$;PXapzRQVX2PWsBMm=2@95}w1V1=xYqZO1yK8HqQD~6D6;bo{ zZvw6c^Yg*_#DlM1W1lhyYgX~ilpoOgrzK})tq&KZ@R$%yv#uD*$)$*Tiiot091|8( z-aw0x)HMYh1qX$_S}Z97gcvPVD#CmP({#GFsugL^W77k0gOHGjb`7vEZ?_j*wAQWJ z#F`c{8piPWQ;oSkQDzbGx8-^_)XmX4V9=nFSEUyJT;;vj%G^4LY=oz{4+NX_w{jnPG1hL}Fc?tb2=yA_V}X(_o(biwMYTw{w3LaZaSQ#9 zgN;qkZt}_FB!!gilGz)SUyoj0JnxP|*MCix-$LYf0l>LKR;H++(B0enZZD&Aw|JL1 zmX*=VL3sX-nFD{$^G8DGb$iZ^xeh9O_o-{I*5nLc) zAvM2-j%cRoOn72ZUSBU-Uu`-^dlRd9KuELXnkc-Y7M`<}m6Zu2BfwQ`N5|VUC(K;F zFLTxNr5%-!dkNNRL+PE=4Ztj;!TPUOUKKF|{N(&%&^zdbM&I)@)jEittyg-M{uK|67#TFU+%&J$lWZ!JPIMv*)Z`WxB$qMGf<+-QRq=R=^e|B4b^KK>rL9Hvd1_AizuC9g6F{if5MSCBwzV z%TNGaHJh38q|tu@X9$PED*Fxn&%cyHA8ZA(8|61xPyO&r;u>#NJ9#EBw1y>cdAP2z zH3y9WCzQ=R8^h?%Xa)5u!tTA1(|@L& zQp_sEw~*df-ZWmx@jN6;KoLNj2@3Gq_?Vbhp;F_+CxruA$Ckzq^^GzBoIIzQTNOe` zOJ&C(bw1d7DfFEny0cd93r?M=aKa?un*w;tk64(QYg~43wH~OdsRg;yb_4hOuH`PH z_3q(5klrzwzT621|EiCgZ-RrWq&-$RO1C;*e>58@XWf0Z3TNL%VPootTCUGIR;2$g zNwn3qe+ntftnXfIP1gV=O9*)q+8%^1f2(8+-@cV3KYF}&QT21r&hS0F=518$S#OAa zD9oQs(r6buf&wDQ8M<$=37s9Uk7UYj%Emx*l2X*o_iUt4uO_(XK_CtnCnx7x zNB;RUPwA56ZP+X;Qz&M6%HeI{6tWxdaZBynAv3G=ZHJDz&E#T*K|1>?it3`j5Jn1T zos%y!9D9$Y;7@panz!Igs1XwKHTga^q3xNcn3*WEGHF}sMI(ocK`8vYNNaq2JovWg zG#No8t9|zY+svp??=9sXo#1OV6eQ2oX4!s-FGD3AlS9{(BdPTLxG*4L@f zb@8)s;%E=x)0Q(aDOF0bASNx6|0N3FchyyWKc6k$i&=^KG?F0`eP}U)TXARm{xoiF z^zYtCY)VnhvbQ|8e_SF!7xtquo`{IZJWd@n4`TB*yO;JNzkXd?uZS`!wZa@%`1*39 z;zOq~6u|-Kz77~D&E6KjfFGccEV#O~DM9L{wn)UT^Lzt&4;nDzWz6}@uydZig%#{EsCdTdfvwQ~?3X5s`rEh5AU-a_s6ZVq zFflMV*zB43t8-^}mxlN04Ozu@uSKI!FUpz=t`%3|dEb?cdj#jjEC;Vl`*V&O-@SdC zRTJPYXK4vE4fom7kwQkrByu83|9r^7MLix)>vcSS{P=_R?V)?R{Iuz5Y(-^qILAtn zU!TnRkB`0-#z_V_^Vvns36dKw&|l9kzb_#e3Iof=#>OXSr(jAITUTZb7@bGT-CLn& z?{QvKdnHv0jt{B5m&QRr&XLh}(2{zize?1Vpor06claZzQAi*wUQczgfoJyl^E>sK z0G(Z;MTO8If?FzPfNX-J(HudwvS)03@K5kk4|2Q)`d=vr-|8|l9$qQeu7BycgBQ zeI!?Cp-V9(Pj*Sfl4rHH57M+s>AGEAU0^Z;oe5nnEojk?kn!07=|?$2U0mEqsu!y# z2!F>0`g<&T)h;UqNpwk4*a;ZpT}?{SR&RRO+O+ky{!;V&k>!uiCq1 z&Gh-lQ@wTC$K6RnvKx;~@836?YO|cdeLiVwXxN``!DoQI%?=kWoT&yy#r7wPAkx$S zM)vadg;W^%o7b;<5wf|piQs{7J==IW$f#KP9PBIWpA9X9lPJZ&0i-%I{dx=xxW#Ekl9m(!Dzg zHso{xSk6Bt(sYpke|B(-DokOwT>!Zfz1mrhZyDh3a)^`r>)WPq`et99)M9*z{mNfD z(WCCp&bes|)2}0?Jh>wso%cioZ#jn3Nea7*VtT)Cf6WnFsAIc4Xf{6;QyI}TJRGa4 zM6xNURpjq+*w@#0PlQy^`3gi$X6DI&b#k)%ZcD$E)UeEW_fzJd*!i$}xG<}%+-LnA z*K<<7-5_?yLj7a$zccm@X|R+-ae{@31+2!o4T(R(o2zm#)25r}N&z|5f7(W~xgmexw$r|~hH+n6KWM5#k&1#s_p$B^$a4Ny z05w_E$~Rc%oo+cWYaKa#R3v}qmzX#SOr!OCGA#^E!hNQtP;$*1&HEN+zRr?M2FxG2 zLy@rN0oathTwKeP7$f->6##@%E<7re8|d#JuQ1s@D_b}92uGz4&6JHj;#&7Bgv{)4 z_?K12FqWZ6utHmsoSa;sc3ytocaJD`DDH&Z*Sfe1%?O&oQp`N}dN`6!O5Xiow8ELH zI85hccPBbq!S0l;^G#Fj2Q->?T!llwj4wq|-E_M{a742e*K63;ydpXdK}niiWTsPPL3>IR1$CokuNPsi2j zOR@w&D~`6N(=&5&^qiklZyh)|R@}7=2?^T&%2ie`umC#M<$cZz`HKm#+IU{RSwGV7 z2r83KWRnHlmklj3n7(L6Ak;FQnrr~}Gx~k_wElZ;u8n4eC=-os_l7_Q?=e3hNgQ1U z+gl;?jgRH1a&6s)&w>3S{~HduVBR_f$uw>&_@e1Gt7T8`hh+$4Kx2d z9xe4mHclFOacK#d9}jh9b!yx&bMT~Z+L&9jy0)CQ!U<_X04%f=>q9^4`;({)R}Btw zn@Y%Ah~2o2O?tzvr}(+0pe>ZlW3(KWu?R^l_{SZuy^*~5%-x!OXUE;)Cr-(0xHq7l zV{6y_D(HH@9dF{Mi6-4~Rd)^rytJZTWo;3mN~!enO?;r~jEQ~WCJcAsb?NZGiJEL* zXZfbr9K7c?bE2K!_`AMx+8_QHs%;K>23{a=r5-mm0lkSKI_Jlav>!jlf4-3wZw?2B z)BLnM#MB9x()aqC^ZoS?2z_Q|q>kKpSyfmCP+~xv1{z@!Vj&MOB=?sAHa4fg#H1e+ zeeOalcK;^mzxDE}_fla{B+@k$$OZED#i>11Ti9pVDCr+1RbHy70Eu&ZR0)qn?vp0V zFt8QGyiavBH8u6Rzx5G-8+TTIJ`lh1^qhI+%p@M=UoNk#G`ODoD`bP3JTN>!nQ3^m z|+IQF>uo|jxz!3#(51 zc)GiQdfcbP93NvbT$suA)c}ZAw|LL_)E1r0RxSHqiHrMetKRyRot7eT$%?`e@4Y-y zMNU~$SHx2WQy@E4M7-J{X*NP{G}{1*V5Kh%}E|LfbSW_&d5mbry9jkvt8sf z`)&63<9eP6t%rJbzro=9zdzc98txcVj$*|wsSlFIkrV)xlkuI;ZTWT{y zsm;U1RsBv>>*0brGIcF{^)I8N_o5k~DPgE>%02Gl#n6w8#EoDf3K=} zq5^4$hK#8CwExQmaM9ocM&9yfg2?$%IO*md5OGy`q45cL5JUT;Zdk%F)X+2fZ@{qP zwi+k+dUg91z&%&jTu(rVKu=Rs`pgtA+WqD3=odd=4W+>*;^5Vj9ftJhP|Kd{9t!dC z#a+_fbcNgLfd4>G&WiN(QwIMUajg<=-bC3W>HP!l}T3I-Z$D3D1%Jp|*s zw5%*5FP2I7uYBiHfF%p4GeVeDOc8Fa`|r_)-2slj=?(z_{{@|pkihnIl`OBx-`hwX z)M|t=kOS$4*3T~fYoW{du?F=#Tf6?&Kuttx+Fc}W6qpcuk785b^YM=c+d}U0^0Eqp zJjvE9H9NKWO{&^nEn2T+o)z{=D!t6 zk&uc%#;+nHeay|t=>!kqM~_VVOJzccSYKlZBE3h0>K}jyAUSde!aCBTK7nU_2f&HK z|NUa$^hH9t@_{hcqLhTAQi}Bc8qbvE)(rz63F-fDV-5db9m43%NP6E!%gDF_oo28u z5Yf%0QSVm>AH4zbnb+|V9f19VozV*+(9EZ%_{4INI%`!5mHNr53NPY&N>vqz<(5X@ ze&4~J7W^C9F92r&cUw)J3vR0btIyHV;L-95j8~SHnCf%@?m6C?VQ0G%fQBiw%T;FF zfldh=eL7#snrdGEU}%^9o=ca)K?Got`>xX?eM_~#@e?Dc4I2(i;DZF1);F9|6xi20 z`u%~AAM4xO=Ue9Sdu4q8&JX!GV2|$FLI8iUMd?(zy{YfH1ujy7xTr@IIO1GDSMDHx zbOH6^9;uRqA38&Hyn_hSs+;oc(Jxo~W%(qoOtXP3a&aIN+|_o8iDhWK13N8}0e*zK z^D6OjbAr$JWx_Bdj%IXEk&s->GYDgngq)x)hXWBL7s@N>>oA?VL3Dvu>#y?nE9}WO z=%YVUQyZ^9Znj;!LIvm@BL#YNyEis>kytk`Ec4>U-VEt`oIOZSKd#(CzJ>Hlg)aYl zWhnX0n8)N#!_#1($6p~M!|{e57TkCrJkQOz_qGRyzin2PDB;Q7`0uXzG{Jz|Lu`M> zD{<&GPj_Fbw#Yyi$M>b7@;vYn`xOXoP7KI*tI~Ok@ne zNEMoP^qHBDAOD8JZb&(ndU+vVbLc6Ifc#nuS) zGqU6fd3fM{H>Z~)^<}xZ+x>y|A-dUXM$OpRs3w&!DXK*}^5ZtJNwwG4W$Vdj<04mN z7e~;evR{5F8T;}~!Y>bSc&I_!^n=+SKd!dh{BbiCklo2=&7xmLPP53_ZM3vUn6VAR zH83l}bTC>xa$Vn6{)m}5f>glnC{Ms6lLm_K!Wl{5mnPBOCWn3HzxJKQ)d*FkJyF|iro9wWYco*!}c5s#&76`Le8^-8+BQ)8WD6Bb&~wHa-o^+5T~-XxBSwN~0chF7%I_BUZviiC>G)VD1K zx=rnha5NaNE+}X)X8;u>Y|_ze zz$2ht-Ieg90(?g`yV{!~B=jqvW)JMnSWRjkkCm9Lul=)=<82V5@KRuJZG)baKRLPp z6@{*7b&uC#di$CJ9gw5}vZMW6zjE@+;alWp!YB zR3lhSbmg)1v~f^lXD3PT3OW}?cxcC4j1f{h7*EIjke2*A3W+E0MI&f> z7~TipM=fAu10pCc0xDz%HC1!-AI09a(4da4htFDO-CW)AT`=qm35KMx>L5HL#=U_s z%Wti}fJz1TYd{1gM5Uh*L3&zRZmWCr8?m^9#yd+chIbDMsZ)0P$Ksjn`<{ zu%VvM@JFx^tpoek>cfQe1PMkPfRx=*DaQ~MLnj{0OF_vX8_RxEQLQpVB+6DuseZTH z1fkF>&q_MwemwH%9B#emxf4YEi?3lgpBQyCpm`}~(^w0R*7MFG!NJfjmC_Hy60rO1 zu-oRn2(g(;(Cz^h#{FhvuI8rZt(gYrv&9?d)76rZhV)&jUcKh@Lz`Yk2TNf3y3JMCmg`n-X0K_fnVw#(4+`{Id%b%L8Fa!zgAKXjVME*WrTIbI zil`ojlp%wailCzLb#vI}oSLYyUtyCsErrMYB+3Nt(+3!BO|2mXI@OcI-&os4?nfe5 zPL#fRf{+FlcH6k5loZh6l`ouSJ@<^)N^{_(p`|iKGfs?+LnTQ_;1Lm2=W_Zx1`ZiK z@3!_fa0^cs8RuCaruAizPc#-Vjr6aUw3X?El1VX1)DP#y^^U>TM^_RO5>V_0M(3Nw z@9Oyuzkhp)xL;kslqrf&6pUAJn4aHY2)Or82UxX>;da?r+~DX+%`89anNI=t#ZW0eO2`O>j<}RZ}s7NQCuewQ~t!UNufa{ z{(>G!;lxfHE>gTXl}BA;cYp*zzWVPU%xd?oI-vMG1Bzp| zh3Wi2bkg9YYpmqtMRVX>_-G6@+?^wzb21cdX6nOGoB2Rd$@A}c+33%d!Wl3j+sU;A z`j!+1m1wJlxwW;mrOw7vbaZ6Bc61wna z6Bt)ROGW1-9?8@5Da&m{bDD*Dv1X!VVHrnq&X1MH=h#~de@5`|c&#R%TUsOo2a0on z4$(2)ooqs1o|@7Q5Ly02Ijt`Yq$~QSaBNQyUz?8c`ChLAX9PxR65=*CHv+~- zU;Fhy6BZ8-FEC{|yYY=yIAdJMiR|uZ{)(x_$hTkA9(OuK>&`JK%-1=m>Da~CPus!< z2UTl7&mz7w>7S5G-#z}Q@tzwkQv6PvMl2CORq&A=^{0W~HU$h?Wp+a_1G!UU0M!MZ zKXwYgt1{dR>Fp6PGa=WNdF$8AT|jFM)3Ftk`8%Edy<9`)NPEop+^Y`9^}e4#0+P97 z;@r06nEsKi!xe(Nd{*RdLLC}V*;gv`<67S%11^r13IO%Rw)ZuimntyGm&Vn6al$0Y zEX{&!BI;d`-5usuIlX=_c4nA-fX2#1g;jejlp-39M`vo2O-;=oo$u25{=cI(^gCaN zETq|$Sy3_9CbF^1Lv>ox3b4K@k8s-G%nQypInG3t#scb%I^sJF^O|}h*)GIShl?N{rkJ&i)}jm z+I0?M2$y}{g=+Y1?sga@L*C5#_g=z?FxjR*>rB0NcH>!YDR-XG$h&|!7xXE}H8%c= zB224~9l~v~NcnsF`|)TFZWvfO5vMO;93nk!ry}M5%;d!Vf*DXd%WwU4?%0+eMo-jc zZv*ictC<`J0npL)pS-%^w{PBL)+vaiS&dhA{`!SP$c70@g33z6KRwA}M%YCTyLd-oKrsV|&EOBvC&{Hw z8ZEfQu8xY@(M|_WZKj)SYXZ*sE$09vg9L8nf&X`d{~a}_uttAg!EWIC@Yx`u>oC&@H2*@l$ zu7`&j8A+rRLq^-67byIHvI1o)q7Vs@cTP^c!v@^|fYvp#NvcFQ3m;F;Elj)?Ik$!4 zp_ypM{kbpTI7GvXXDQxSIqaBdskm6)dgco~1oX4&zw^~B)MZ*79UmlaiEI!Hr>*8Is9?d$24qnGek&sjY;Eh`g;j9KQ>fgERlJM2+`5i6*_M! zjCm}4gTmPmoW~#MZkt8A>hG@y$&BDfONKkNm3RCPD0=kh(L2kT!kr2$?zsO}zuvQJ zjPI@YRbqU6&`naIrEe3@mLzoZ;5=SmmwLKm8O71~?pXgOy3N9m@+%=c@+<~!(c%ax zUnme7&$&(Bys^a)?|;w5+8!;t%Pi#MlhWX#@OTqO9DwmX0395*_K*1gTWve3%vnNy zOpm4iR=X=8t@{72c3oE&zctjIjj|8>-8ph_)T(Iq*8MvEyW-Bi4>GcgQE9)lYIB$C z`MB-vpG(Jkijxc!gVa<5C>~()Ozf>k%iA>ALmCBo5@HJ1iv^C`RbnT3Ule!kocgVhcfyA3j%G@{mO^M?<8djsd_ zK7an~uZbo4Zs#d9%3;vPh_2e$`zlnIcyqnJcO`8uS<0hB_qLClJ3hRlkyOj>IyR-S z$NAP~w&E)>Zz3c4j_<;KD-eDleIhz^OZ>hA0VUrS3LyN8~{NMhKQ%VPVAf<@c4axc+r!|~_v9YoE%Y8){9C?H_A9}O0 z7FZdYJNFY&>g@vgi%iUD_EW#Jq0$QE-&!CT1@mVH|DO*Kk2US7TR&>da__{5w|o$hMUTgp za1@CA`|<9;+BVta;HCGPonBQm=S-FlA^S(xlU35tSg|QWQ;G<)vakrzy$27aHp{IO z+OxN-u}i2J#yw?jw#b^JMK}d!OcIkFR8d>gb=s<`2cjZVrBc|IUG(4@=}2xfUYZk~ zt^&FnG0Yl7Y&%iW(e7slObBUN*@=tC>+A8vn*Nq>W zrRbM)ZH18ffm4SCO7gy}qng?(Rhez=ciD4<%A$8d!$SX_AIW>}Edr6Q?V1bz4w*j= z0i|-vx=PM8X9@ z>C#Z|O$zzMZqv;=&1(i2V4W;HU8hk85Q6H7XqNZM>;9a9{Lcg|NcxWXC~lOy1q2=< zj|71-GGz{6koljizZo<`h9NAi)+p$l_ak z>`nW2KY4jm2F#QkJ7<1E_&TK*RD`U{?zasmB6`*MtDrofDSonweu#0o}0gGN#Z{DMYXD=7%PxDVH_T{Cc>6vDJ?Axf#F{v(yA|M@9Uguq3@i`B%n8d?Rm8q zAMt#IXUe@g%YQli8^!2CA~Z;*>+Q8?N2Ps*-{Irq=Sj3&q8dS=q4z`4hPYqCB8 z1RH0p4A?U9D8T~H$wx~{BTsRH{@GCTmP$`Gr97bW!|-170hu{ru=A{4xfyk z=q;k-#khb1sY>`-y4s6Eo8vsYo7qflNNgl#&#t?fv%&p1d^-coLIo?Ysmpr@1_``t zOYMdpf5^T|jl1^?0!xt+6EJW*7nbGoM(n&1K1MLoVIsxo`x0T;wBC@ZwyjrpB5xut z9O3WpuQ@Xhv227d^(EXVu}Igf$XBDygH(Qgn#4Dw^!8r051+_m2|BL%%J;^8{TkdO z%qr1z?I7m$j?Eb8ImcJwt%Ffb((0A|U(8&8cXeS1QBpbyU%1(Eoo7R13w^n#JyyTo zt@8=5!F})I<)^`S3nR_Gefck~=c1y;)y2xtD-!l>bM06FW?*fW`CuQ~4BMRd)5M(> z*-2wDznjj}nzXds=*G3l>=CHX?thRAhg^=O|#Nt?}b+3DHZuzrXTKQhTons zvE?31_kAOi3)qIEBR>1zOU5r+!e^x8mpt5C`U4s$#Sc&hDf+Oz*}m*HU>vc}r*qza zKN{%*n7JjQ6y`vOEJo1qSrs~NOQF5Oc<>$vkbsalHc2$h(g)wOv!yUzTUq(wypWd{ z@tVJbFog}{Arjlh-Ln534wmRJ#f|1baihm5y`JKI5h7AImIKI#0y$>80B+;⪚|0 zoBDogQRrx%jEc55g%1(4@bJXU=M^F#$)2dO!EWCyF6Pv%afLZ^)`B{O45?w((yGR* zv8=z)?VT>QxfY!t^y0tJRd?hGP?UkQOu-;n;lSidLb<>j?gFgs?y^11>I za$-Rt0hMb(J&sLa9^GJa*#Pi0mF_v%InWk_Q*O2u`}m$~I_Gs@i4eH~QSZRa+Is8v z5OeX;>Hc!G-e2eKt8Dh!)hF396nMa?t*~CODgS*2$(W6112Wkk_9dsA*35Ife8@g< z)~0g#rdx4nxVw@sNb^&w1cnnx$Vg4UhlRafwG3S8v%hfJXdff3jH#nXyB#Ptu{Yj#deuJGgWtYhNos|&%i?|6BDZ^=sb(++(VddC02zHv&~z)i~=?e5vxwAdylnmFAO^}0w~1O0=qCw;o4S2 zfFz|_lLNxv4a@n;`;DwAmuyCM?s|jkpF~%CT!d^atBmK~XlLIm4XNzJMwg?DvDD^( zqI*6R{sN(7E6>iXQ>HzE?9)FmU~gx~AfL1$@^|7}7_yJj)vAiqVwE&*{pnEu;AE9& zK*ISKfPrUq8{b}kd4)A!(|{Uu^9+?WG%9}nd~r2_^z9K zW#81&xYq9U{&hYwfUaAV3(H+!MPC0x!gR!hj?Q0gK5>M2(aP6^3tj`_hcJgjGGLPT zss@FIpon#LhVObDivpM-c_7b4!}($Gc|CM83Q;d;fo#7~(?~44=^s`fK`BPMxi(y& zs|{?7gak5TDq?)H5S+)%>>6rwHg1^6fs5G*{I2~6X4Av!!=PL9h?TYOc=ifJ=!=P1 z%EbfjzihV-jE}8>4sxDmeZRXuAHwu-d`K}x#L>A2`i&Omi>?m&%#TE zt0vwQ09TymK+MhTLhDvv_4C%ic;!RI|9lQ$J0&iB}Tsde0{^t=;Rxeue zb8oC9o2DHB>R~tt;P+r*VYU2fy0|@G$EA=M$^pN)x%0k%FDn0;_2B8rek;3IN!p?c z+9*$d@C(oMgzNntF?8Y+oa?{e6U3bLyu7BzI;4GjJ5?AJ0Xp^00N#|x>Q8xXUi8@U zn@?R`DVZ4h6b%%MjyZVp*%zT;K0n&fEjFk3X$%Yv4h{{bz59U2PW8Pv(m4S#H>p*w z_b*==d;rm*R~E;n{ou}CKdw=Wt z)OEiq9Xe!!*ZkG|*W<#X4-MHfa6x_cww6>(hwpb0Yn|bG9ZYKxCC_&W3DCme#pg%) z22J>QD)s)e|zB(3}VLD(sQ&Nn?J_RTmrsz;;&d9BkQeA757ch4u$Jg(N*B8gGJkdEt&AV%t$gr!6XNP# zpuaFEHkROdtodRn6kA>U0mkps+s|fPDC-WsvllO-=2~vACnpFxJze~Mvqkt-U}Dn` z-saHY#1Yp9$eZfkxYF|Kaud~!J2u^#rEPE0+yQ zzHSXtwa;cR?v|T5#f@hM6Wj#ysFg4uMpMDRv@ z^In~Y4s!wgV^A4;H~pvswa<{~-7+Rtn~JdC*S&A<&6eNPklA2lxY9sHI#?!m<;cmz z{6s6;kLwaAG5Lql*K2Fmv#F%$T{t4KPq|1D&lbPhOA2v)N&fv9N-2B>hoOGJ8iLXO zf^zcPXHIzQ_>iZE8mnAyo=IYD`aYp$gYjtE5LZHlMC&FkU4*h4VUIq^N5(UEbO+~U zhB4e96mk%OC2wiz=8d1-~ucH|x}HDkQ0p5D9UWJA>lsW}~Y__?j7t4p{zV&7l|ez5&g zTY+`uI>vJ_=|uVCLVq`4+3uXb-+13w{=HH{ zJ-Mm*39rNji%uGSsgTgnsY*L61V&6U9?|JDM#4%>J*jqd7hbl_;T!-sXJ^y@?1!&g z+bm2-NEBVb#dt9v6Ei)1@Q_0%eNu^48G1W(2_HzTQFtu4^+`bY<7*Cz*==@0buqUnz;S<3MW(JeiM_{;}VCHyfR zWxtM&+B_c13^pKkHC4R;H zk6~QSr1iJHTywC1ZpzeyyOdY{GVJ1trFKTGi@wikaFO1Ui#Tv+$Jt8jS#If;^aL^A z412ZIqmKg@jPp`^H1R*0Md*DTw*Va;hq_(z7Bk^K(jTI!r!zBbH{_8;_0;(2- z6P-o0=aV?`?&*d>spLU4E%s_E>(__&>FH_H5y9WV(Yjys>Qx8oX`gzAFt$kX^(YD~ zR(3kdK%PwfF<9Sb!@mXnk&Ki*Kuv-GR^Rrm#F*8dkD z@mZ}3p^yf~z*+GUGCmH1q*48EvozT1yQ&%FT|NCx4!i9jQ!Ohi6QDZnOS=2zouGp3 zNoHmyR!Ir+$Zlq&;eFkQR!!yuKzfdUUq_q)DsXnztP=Pj+Y=f&@>sRX>W+Zgk#Dp{ zblfd{bR?s`VU$aa5F6u;+s4(aUTgbGMWCMhX!!gczBY{vupprU*FZN_l%LPT&F$uB zX=`IzV9vC>yo~M#5%zr6P>H5u+_9ov;i89+L@mdqaC337Q2U9~c&0}m8qpEDWX~wF zC*)4oyE{Sb0#Xbz@<5+2BluI2b%&F(>*lrv=q`1n{=_7g>23*IpSj^7pPZHkz6rT~ z)K7eerA>o)9c~#PBsZyGC31j$?Kkx=LF>WhK&7>JohhTYi++L@10nFalMl1_7G6kk zN8S9q;O?*J_E~!YgKxEfR>EqRr(ZaEVUn}}{XL}w4_0EXIYC}tUMnGLEy05^FRj?n zuj^(PEcD2usc|4a-^L2ZvZ}~icDHuD$@L)kr9Pgu6u2gM(^Cx6kK5Go9Wa85tS^Fcd-W4sr^L$p=#-A9YmH<{@L8 zo<1YD`g~iM?Cu^IP`&Rc?|-nYp{%S7nkmp`w7k3=zVfwz4`-EYabXd}s>%I}Y%#{L z>v_m}NJvN|-NnqlOaY5ED}8XcBk@baKmrVOfLA@vI=s9ieCFE2my@`MYcz7i_-)aV zU}{CG>mTqXY3*hvWYmsg$8gK{6J>Yr@X!x|D5$5qkClbx2ZbXg4Cu#?o8>FZjnl{X zAVk6Zfy`w8<2zEu?4^~Z+S}wBJRy-%!Ca2Svg=a8C%5lt!$wLiI{)>f&P=0)hG}Ou zPHlMci?2!$rq9L+?x3xKv_+IKv*<&PF{{>~iJ87AW_MTD--9M{{g_w~ghxaTn(-(@ zdb7SQ*hBA-tWWGNYUHR~d=6~@lLb5;KGr6Tqkqd`go&7V$#EXJ;2;khTy1{N1|qj@ z*Q(p_uH9GMUpv}00pYq_fQH#!hnj~eYNBtLlr5LndLfDu+Au{{Q#stF`LWSZ7n2hl z+_h!ga+=+5Z*Twl?fJ7pjQ|Dpyf^<=_weYbOV}n^idx3XNM4I@@EUH6SkycC*)+f7P*}-gvfuQD?6ZHkKE@Dv&IjjLCrilTcFXn@~P+_X7 z><+>P+Ou1G#2`8tpp^o7Vv95x1#j6-WGwKh=%13m`MRx6DhW5Y zi}7E?z(hW^?^9yplid?e$R+lBsl{+H2BkY>jYs?wq*RiUQdh1E?#;I0&6+%V1ZP$& zsD!_QELf!Of_PKoDWBaR4Lw5akl$A++L(W+rkmUHI*^KgR{fgX zUu9s~cA=9~QJK}S%(bOoOE(~n-Pze$Xkx#DePX$80oN=3=+T2`hnYCUrp%GxJpcZ} z9aM+S$*@w%Mclx_#lKr@C&tTHWi#A9G_-Tx`*?70Gx`l<<87#z=)8+mQbb#GN=siH zdYzr<3O9<=jJrKTy$q+6J^`@uMCDJ)<-2vR`xHjhMw47UQh<`E;Q4~Ss4~bG(u7g> zQ9qnM*ST!~F4V@yO&lB;U#b%sJ6X3&V9 z45=Hdc#x={9dUQDgH(0uw+&?B5eE}^aU6oU+w4L=(MQ%MVXmOaNal#A5ntvN=`PDG z=l?7mzkK!!p$*8}ca5C#b-Lj|*tlWZvPT?&Fj;OEl_?!Mk z@K-az{?Jmp&oBjfy$(NRQjMvvvWxyum1L{Z6O2LFO=N=Ky?%WgRd;d33qhQ-I3E-Q z!(@zbRnl}_o0d4OCtFw-vHv7KjF}B;JT;y4KBMYlvl&Bj3lnP5L_QM=!8;Z!F*h&# zxx#bjPG6V1?^$z=g7Nu+Q`UHDv=pd{<>`qGjE-JkpF58>LaLpZWxdkXRaFTb1kn-5 za=GiW#t^yHp-ja*cL%zj-|?=M>$g)>qMY~c#Dj{6fUI+I7jDnM^mIKCy?ajz--2A} zXokHS^+OaXx0aKHU zTvbH{No0p70c9QPlh2LFYXoEu^txwi-9rmEPyw0XyY&N|?4f3q{r?+*a z1sK{^*s-A1wud|(eK(3cdh%oh@+R2WKF-e41rK8LDpwI^scLFw&o;%^sdQ1NaH=jC zd7UUw>U-{7d-kjT-cp-Fl;%?XMX`{8U-MqhP6sq;an0!g{_+ZJ{I-*_FP`?FChwpQ zM)hX}yWQ%mZ;#CJ*_KBT5f3+$~_n6vWG)9d)SXP%lqUARon z7tJtTVwG}&qhIC3m^V9XcstJMeu21t|1vK%F*n^h$R}U10wUD`Unx)u<-T3}M&FHS z#djy{*-lK%EuzV}_l-OJ97Z0#X$9kAsL}uogC;`y?O@2qr6uGD2;kOa7G?i{aF55xB;ZW7&vc}VVA@S;D3eoUkHy^tMX-pvw_Ugz|* z-@z7)GO${ACP^bdpicYw6Ab|^jKX~-!mh19;U7j5F8tY#lRF&tL-P`)MgYQ`|C$wg zZ;8cj695SGPjCSAJlGt3tIOxM@trn8*QYEjtkEMOEhmTHdy;3i@mK~4f%DDm*8cYB zvB%91-ox3b_q?IiDWaif(bAY}zhouqBm;D7pX7BiJ4Pmp)H!b`PTLX9urNdmfYfvM zS}$`4sqgL=ez2;W#HV5J^b}=krxq;ky^9PckCGjzd9+h%-(0<|k)ufHUbZ?iZ87{( zskOmmtWRA=+kC*Eq%HfE|}yX{NOTfd4aAfv0*t77i?G>b6&T zAG6RFKhD=KJXCs8aJ?>9t%}ezmHn^XCY7`4Hd90j&7D5L%NYOQVW6YloV9{bn`|2! zKqq=R58{J4=E$4AmKY(y!Gsb0bK9WQ!a%Oyb*ACp?#j;34_gyouop0-uV-i{zxxE+ zq2A~8WpFRoq0?M8%8>c&;O?N*&?K#pzR>KmUD*+um1V6wU1Q_4XU%}HBwiNwD~R6#;vVc`^?U2OGm3?FV+ zQJD6l^hKV(ZxzV)a8SgpBExpstpXyRdo+^gzV#sQRMj({M}^~is`rxqK7Z*P0`cs$ z*iU=_pbW#WO6xqwqSzQCJt}!JT$(HI#9{~@+|hYDferxDR{M>jkn1!K3eMsXh_eoUrGLDagms1x^nx(lvO#BzhU zX}PZNSP{Q9Y_6ahr^UnIIpmRvO@rCN4A?Hp%c5UO?yzN$JQ#F3djHpin~bA#wzuG< z*W3N2)nrbLI*u%m)4{VjpJgl3Ev!R6Z-qNkjFpwOF2b2w+2ERUHzzwjV|R}d)fXyx z78p3cem(7FjPiTA#MewW$)$Fa;}_v8iwkYv-I~X!-QCtEb(Q9euB<^`CxcvF>vWA& z<4)Y$w}$o3gb2igFwZ?ji~+0)&PbDyBI&#(G-cqsIqCc3dEz$CLk_o@m0}z--b%7$ zkdUp^q<`sX4ZG)mcXoJ0@$2EYREbd~%X3N5>LAy^lQyP-;t-1F92KMTdkH;z6X>sq z78aPoejNx5Xqw1SGA{Eq--YjVTg-%aTZw1}v zQgh!symRzi=!H`e@~K`j21}4TxjMFFDHP{cB|5Q4&Y+hfoO*K? zeNGP5WO-H$m{uvnTh)>uzq#Q002cTWg0Qd;44jWtLSwM-8Le?>E-2lQ4;U0TX~QsG z`fG{eZZMTnn0`4en>z$3!(F6d30ui9Xk$%xy&M)^2U zN}Qy+PM(jk7g?sGA149=F!tr?t_Z{wb3DhU5RJjkuxJmV&0pn9XDD9?mDlj}gnAa# zNdW-?F(X%lnT5fGabIx?BupG}`mmBxQYtitu~3!RdgRC z`e22Tk?~u-Hy*NPy^gteO{9FVx%u(ulcV{oM>*Rn7R*s-bv0@Og#@FpTwBd(o*t{S z#EhH+0EWAF?$8g}J6k`sut)&;^wuE#PkMgopjyX3frmnxWzv4MidUyPb2%I!E|Ts} zGpOTq2?!B$U75-oUkIk!D>K_>7#iK<5^t5H_#CFjlis{SB_EGXbLD^C8C*FPyYeDV zhZGUEBb$!wFTV17i;gvoqFNSLu8T6P6PA>e$V6A_1Fm-T=MTELv-94W|M5AtTl!I7 z=O~aLV*I&1E+Jb-C&gR#!47mq($|HZw|=Dgr-=DJhvLnTQTbh&veD-67Bq+&WNIPfi+i|9o%#S-K>zh$p=K4&x{5}cn#J(I zJ%4}0)%*8}?vDg+I-HB>66WSC4xpb%b|oUe-4$N@g+|1{*&Syc*Z9RH_tu}@f38wf z7tyjE#*W~M>-1ajpP#0~?__j9pg-CtaE#}DL~e3%aglQBDo*b%ekszgDoj6O!ys<6 zovLJafaY83(%T@&_-d!R#@cI{W=z71VYm4~f8O=}P&()dL_N>KWGiteu#P^b1-RSt zdaw}68vN@%Enh>*?Jc)~;<83r2_d~FGrbU^08sGM__zS)W(z!(tcD3X0bVz~qp5C) zAVL3R*Hl!G`fD@_XS>(%bt~2JhN2l$XyLd)`8~ zH1qhJ9N&#k3-Rv6|KL=gXe&V0#g>yLf#$+#_F_+uW2K( zVQ?o4o9?`+W`^f_jWdAHb~^rOI3YDt8>#hBt~-j4C$s=TU6Db<)4uu|WD5?QZK7Vc zd!4E)Vn=hLqdQb?Y);g@%D;A9*nDfeB$|`UjF2hhTaAX9nb{%?EBG{}WWF}LmnlgF z2KT#i+%}^x^kEcNZ-hb#YKhIBsOQD+izY~@B^_F5(B$(ge<|{jVAPB#3kE)8uhYjG zsbK6CE^S`PDniCI8DbJ3Oxz4zUB50rhyIE`eP;tuIIKZE3mhj$aiC3BP!KYnes+C8 zx5QBf%JpZ`($Ay=?qTTYj9s5PD}^A(c(0f${)AkCDrg5XD=BrD;KS`M{QdWJ-|c7h zU-YYj0szb|FsiftG^RoGk}LG~)4h1J{@DxY7tm<-9Z#U6PZXaHuclf{LFhLZ!M3KdOpivq-XKhgECMU;%|S!Z99 z>#aX74u2tFG*R+oMTCZsjG5skm~TcnRS$>Bm^^S7HJ`yqg(9}*Z&vsh+*VG0m<~;qHrEMNJNif5x@Y11rj^ z9ss-f+Dx6@@mCvJdBZ&!ScZjaT`%2rsx+EuY);Pf22R<owbZOb4_e(DYzis~P$2w?) z6d#-PWsU^UDE?B5Cr>m_d~XcXXO@s(BbZ*_qh&b0JHhlg5TP&v`*Lp`w|3QZb%j)rSui zud|L;d_>2ZpvQhOB3f?6EpkpH=Ei}r#BQ+{`V-l>D*pxzLCr%Bl06{ZV|`D~n6@t? z&-T_PYGXeb8PdUtM;r<%_Ai?L*dFjcqxNnSvFiKGlBYpfqsHM&@xB)*^tl1*(FVLg=ji?h@W z2@GsAnyT!N7o}g39|7T%QT;PC1Qbozg=+E1g){ADd{~9TDS$nF1FX_Ht&^^N=;Z?* zmaV7fbm(7{5d1pmZMt9Fby~P=^k>3%{<;MC?f<7_>i;cYYd^w{yr8KF0HAd#o(w52 zDcSh;v8uLBqC6C~K1h2L_Bjy{5(;e@+?>uE&QB;CVZZvK7>89bJ@7B8*Y%$AeF$yG zLBe)^YxhV4#RY|-hU!+y7=OdcSW%G&8i9)g>ho!e&ygTKz5fpItA~`z3Iov*k@6Ad z%;%3a^DP1C4L(4tkspH2OB+dbqwM(Ubl(}7`xE&2#l)!LY_F_SRS2!wfD&7Ii>F37 zIk2tPMObk0d_0a2frllS{%nGKU;agIG!T1H)b`-ue$QuJT2iT9C5`=_A&0^2=~MLQ zHYOnZ)ZfPb=b!Tr(_^bE{10AILNh>|2F}%f)jGHIBgB4KQvVl8$sn7%f1Ns(YVhe+ zDDdPJ0Zgt+eTn#*Rab-LZ$dx2t}ZkLB-J^;s)=MN^ZxUt*yK@IsVN-Z%$j@i^RG}lu8w}Rn<>vrzXGI1PiHSQZ-B5QXuLS*SVKp6 z{t4XNc%dOHUl$-Yxa%g#dMO-^p!LQ{WPQQ=XNVPrZ|Q zmLdur@u1xvIV^G3;{&<2-z?u0bMRdzM`!?&hdsed)M#BKF!}U}dbr}HYktPpK58S3 z333+ZL0ki*{)X35FSmR(s~X~&*9ow;9C#ft^0?eQy>z=@3C#};vEP==_4E7eCxz9A zc`@pn#W~s`;?Uf>Je&=>rmjwge^F%fLbr^6JokIUq`0@WMc^|Um6Rkh?u3nuPfev< zxyzh9nr?IrcoIzH#`j2@@#3_5=D@(TDfnD`uIs}M0t+^xcS%X!d*g#;pC!&s;vFL+ z&Y5GK3EU^JnBO9D{|Be{T%-kZnEoN$A z>T=lmGc`38Xayq6M~`m91w&2=`1^?Spwx!eXNhrfa&-1J(*|L`9o^tbzkk^oIK79M^89>-Gq=xyViLM= zD1_Nihu0CkyHJ29;Sp*{v)3o9hZgFma$gN+%1{_|sOF9RQ^UkZRzH;Aim2s z3igNf1W0@M^{Qt->4NhB5%XC)EZGX$0{5G|&%fzaRcpaCS}8cnwJrn=qoKKNe#;UD zK;(RR=J$T-@9%%l%R@v4vv)lwee^*=q3h~KALpC~7Xu?>jqAp}X{lG205b`bPNd(|I**DqM~w*Jh+FAm1XXX=J)5@{l;|eqF#GJ z_onhpn4?nTh1`Cz$3qG)&00cAitqeV6bOYb9RCg-58C0psB-UKJn9?ziVixmqYLdZFZx-6IIqZFFM(1$t@NzPX!{7!w5%u!T{H_^h>md7q<_Y&nt} z_3W&?CptE^0oW&a62VW%{zX6HMpA2nu!)u7+O88X;fE|x76La1z5LF}0eN}Ac&`Qn z+2g!DB*Vpeu=HI@;2v$m;|DBE-3S7&>haaTL2x+g2v0Hk`KM3CTU+;unY!ckxSHkUR$Z3FSFkgCH1OzJ@xpJAdZ}iv4Im7_1Y>v1P567>P%mP(li_eBldQn z<<;!=vn!8HK=Tf)06O{w1%)Ghe!IcZg2|)dCOZ^pj*w>*)SO&u_wU^UEqsO}X+kcn zneX}S(8a+`W$^G}fdAva7*joWcR>UW>89c+KPIMjLBY*_BLTOYw?JQP{~=L1;EqV_SczBXCdqExOw`LZI#)vKi6YrT6=nPN!2$EgRT z5CjJY!^DE6m0Ga5XJ{y-gU#n;z6%uQP?>;qiT|u-xyWWpX6K1IX_KzcIi7ENQoW6j z|J(G0Ye(O&f6)vk!BnG7&o6dI(r^+;?g0nB4|L5%s!6={y)>Vl%-&EMG6G&2 zmY|E6j+Z!L@hXu2pGjg191r?jP;*2t3 zj(C$IM}=?JzPEfsr0EW?&FceN&J)LH;*V%j`cbBXl1@&$;87nS)hFiyQ~no}Es&oF zXsM8<$9;ODS81Kq|1&cy3ouA;!5v44VmlB!BXHeT2O-a_%9Dnif#Q@)AJyI!ZhWu# zSgQUS(qBOd!SL&!461xzeW=!yZjA8_#&p{5Ptuo;>OQ&Qk@m)mUO?U!Jy34c5EK&P zJaMN*Mm{oI{q(=xtDn+#Lt2VVF!mu8J9#ugL27QUVw>+Bx0GlxQKQuECK9$W(C55q z`r#BOEy-yZ5<~dz?c2G@JW8BzzCRvVSY+8RiE$dbIj$;8o~k6LotRL!6ZbPJ=!86} z_U^c`;t-)+AnL_Jgb&Lit;BBAvF^AS^J2#PIxL*ptE7xn(C!mzQa&Oc&y9m-3EzA@ z6%nhSvkM8j?mIe@L#9m{7DWb;gg7EjztKr7O{`$F_xNXwp zvvWr378m*xui=nvNbHiHbk-N3y!YQ8M($ka&$#gWpb1yr!YJ7NtA#{<1*Wg?#xgvw&y#&8RcS^)i6CCZ!Np}8PpvRqgv!n7`y2U z=E_F@jVDz8v5JKuS>mC$tN`CqLz8@rBtu28_=c zM_IFfFpj#Z%m2YR4sjY~O_kf)SXkg7Iz>hw^Yb%j@i1Nl1z{sJAD_Xh34U0H#Gkvq zXS5M^!omTk{=mq7#QXIni9Z#E{G_>lJqc|6fJmSrP)5vq+;RXm@=NcC=Hu6}U)%QI z5Rn#!i8Yw5M7c5puM8Rq1Ea*g?cwN0-wF1fAw~t6Mo~{FX~6z6>+|MNcB8pO%FWFU z4rNGCXz1}~6Dq(THtc4)X0+!mKe4NBiZndNlZpLlZBO8E_+^W7nOB{`8O}UIu5m+F zR-nMRa^(tA?S?Xxw+y+LJZ7g^U~&U`$qP59;9r6*1}TO}i7E~QX-PSE&&ccCqzD|S zXX<<-gU8ok8~7(7EbaPyX?YoR*x}WixQv8;`^o9APoHE_SWt3Q*2Y_-J>ZWV8Xy3& z6|;duoU&3cC01Uep`vJCiYT4Yd`?n|o#Tv(8*?}vmx8Vx)9dQ&EfpBQL7Kq1MI{6F5YNlDj{Y3PgH7yhowd1EAD z=PEYF6LvHt>^isXLrlm)i=uA`KHh%QF-s`x?zsx-UolO3K4;sotHOhcDAb}nrr@rps+r$ZW@k%5ynXYT@QuycECpl$LYKi{ti|S~ zt4lu7+V#^OAeQ@K=i2+(XQzKIOO;GS`)w_@E1?>cPc~iBJI0FGv0W=&Tep9O&l@S^ zA6yMwV`dw@H)UTk+V+9uxxp^Wg}=uBzy|u>qmTQ#c{~*ljweMJXea&olr>u;$LSvE z>Z<8?gDOJd2!h<-u2HQIr1>98s)VHZzao-*_>gL0BE!>d7Q{$7!d-kGKso@y8c8OK zj&KATfcB0)4c+fY`xi>&$I1$8eHo)5Bje4RAfB77x%bfb5>lX7$q)g#Pob||m)Dq- z4lPTn;=BSv!6oMav=zr4bQk-At>1FLOqahZ?co3VV{}7Dy?LkShI^_q7fu5i>zePP z^5~|uB^l;Zk2WfqSjfLfSPO8q++a}B(-_7iBH}V%{SQic4Yf5Wbt4iB$&mHvar!8J zPAQX3o>R*FuKz_T|8*z-O)8gUdL(}EemUgBUh-YzYH3QoCcRBdoA!Bm;rb=x5|1?b zQm{lF-hh9>SF^045z}?6?biY3S0+JtiV01k>8LB50vdBOvvk%yHh&Bm%AQ+xh~?4; z__bHwbT)V%;fRMm~o2xaa;0x{e+K*i)Z$_P_q#;L#O9Lx9b~zgwz#ysL?zPY}lV0WY4BVWO@+ z1eGaJB(yr%uQVqUm|h3s`WDQK(&>T-o0M*GjXh1)CL0Pj=GTD?N1X}aVEG;boS?6oi;Ak-xLABiZ)4o%?8LYXN zcBx#UzAG*+-n{0O18$q8L3-piH@DSPc7eUd)sW_lY}KnIOEkddiXXVN{3u!NI!ZeO_q=ggh2eQo3uSlu>rqw&7rSC zFX~H}_aOmdonPSX*!~HObt7( zKYJ_Q@V)qh?F<`y)~D0kAh+{=0DNKWMU5)k(VwitIZ8$~v-}iU!&E#4x9IpQ?tIRx zd7~f4QF4X4UCi_Cp;5YqQx0%cXrD$#%0Eu&q;!(4XjK$HU?HfjPTg7+I{px7tPeQm zjG8E@kU}GZ?<**P`n$n>mFb-Mzc<~?@) z>s3|7)%%_?w?}_#Qv_=QeVkdG*SfS6~HDWVm|lOgS`Gv>HFdC?vS0# z4asy;Q}`Zd{b#FIJ&8$4UFfkwZky0XNLE$`{aoFei=A}Wg${ETRKTFiP*A>)`8-*~ zBV=CX+r}Q<&%hULLjsx)Nt0GK!imIA-{O3ymm}<0pQ?gX_s$^EA*&}4+8mg1XM~O9 zT6bw0qeFenCY2WMFB_$X-|!W|bVpw&*&r9eV?(nk@iu15f_&ML$=E=~q7zCl5|SL1 z6ifvF)h^(sL-^0X611-8codMfaAf}*API*r=Mk6}`}dcVUTDGU!jgc5xehNu+>C^Q zsA~}sUpie_pxaIKwmi*BH_5F1`qn5e7Nm$d-rEKlJ@7Nim6Wc;lfLRzw%>jxx4=pR znscK|(m4^=Wp*HS?P6Td#ph7ntY{xogoN}wexhj_A?D2*MfMY9%eO=e?NUC}5lV8t zRIft2M0H!7fkLW5-68GWdDYR=CJ-%q>TIoM-s4 ztvJ+zf&#QkZK-1y%`?;riVTe{7yeqp1=J==b~%I3u5lzXvweV5h8vH-ZgAYkMSuaT3r%`D{_Wd!1T@2uVkm4xr1k(nO~Qx)P=+8u;o%}4Yh-Y4 za=LbwDFsXLNR2UMNk?nf!u_x?dHySPwp7(_hX|!`^-320%cppdRokB?g+_BWQ3idz z&k0su!J86fFFmqmB^~zmWRJCZCm^qqv3;WaNX0?rmrVhXz^kix2uPVBl?)4k%&;gK zywW@W;;p?Lr^5xJbkgx}+yznmXUJwQqvLZTN5nUKF5<)5&&Wvh5~W}qH}8Hol;w%o zs8vP$d)shHsTjXXvc1~pTf~8zN`<81mfrig8f>o^q&)}PgXN!UlTHkBC_a2Roubc2 zd@w#}A|u%mc0=mrcmG%T$wIz{3?P;?*^X{&74iM<71a;84*(hXi>Rs#mnj5P86-T1#YsiO*gd3?fi2`NjBEhVybfG!3$_g5OOP$ zgN;qUNurrIFreZ1z-GMKxTXDa$XN)*FG0n_8MO>AX;&&j*yq+I2Veeat8qFgJjXKv zU_#1!;&jv( z+-D#TK7U3-Kp27J>+66<%6Sy~N9V9AX-9R$=fjxg9iDF#fw0~a-!XSaBdCpF5-}*@ zHK7>&vqF-sjekGrZEDv0WoB?mI=wUkXEto!fCfF@_&x!jqZ>2YWF)rj{vx~r?8#QY zdf($AVy8PAYb{vd@YL7W^=7^4elowE+~C>i7y~P5Q)fmJfLso-I)^-bjD_i;B2ipZVc7L(<{;xy1<4;$yQpzlv~WJ}bW z>rd$`EfY0U-Melpz}lQZA|RmhUBlUM{D%g$Y&VbgHVhm;B_QN88T1)+T}j&9r%zeS z>FUiQlc6(9PB#B(U?l@ii*%9swGcj_Y)`gMuS|iz8{tOrJ3$vTXaU2=$Jf_$d>&`F z^4-HBzN!MwCT<5CBgVXRI+wcq;ab5-QDD$in40>ix4OisF=>5cR%~E$)VojY17+gf)UwA5A>diq3JTf^!j8fQg{DyD zsj0CsEj!j12~*Rb_5!P8gEsW|E0_W zbz@(fjGp48N7f%nv`;5ByQO7i%2*i4Cy;hBpQxxxfC}zse^b6*ncic`Up6A$Q|}cu z6ZOmOqYP8H;+Of~bj;bzY+sA)n5uUPy_X9TbC9%RdWl?oe7=ADgX9i=9AJ2fq{i6e zAFOtx41k;;9;HjR{LiuP_kZHBcN8_nTl9>=n@7IF{lzSISk`H3^p@&-1tzSBwH*c! zb^OER+b!T=_b=*I-jzgSZ|S=U>bv(;l`VJlS}?iu+-sxa-wPnEfjs)zduRo7cG&5-~fiSzET0OoQta~>AB_Y*8IsupwqdA zxU8)lC0Ic#1^o3_@xuQ$LUTNQ@`T59Cq{wxTEE8e_CK`%jE23cb^+@W2FL{C-o4Z7WrO)w zSyn-O!_8C!e8X?wYO6kYWNvOgF`#a65EJ{qdF3!XHZ6b!pwIW4gjg*#HQUG5kIl@O z9g;Y}nO1Fc?1~HwR?O9;K9Zq!1~fmbCk{<~yiLJIuPTZe$EG2=N!1$YtN+PhV}2Qc z25@3PLhn9ngY_!hnj)$t=t)cMcu2P@M0meVetSTxBBxX3kRi)@X4(DrRMT$NDros> zgh#9&`<<&R=;(elP)PD0h{W}1jr=0u`LM6WsknY4n!t2-%R$(o+-^*-E%Ix7meEz9 z?Z7RV88t94Q0%_@E5Aod>QeB>PD?sgw$Q9BMnS(4y^U{0lXq?%OZo9|1O}ps!~E2* zimuWb{>pZQ*G39PHvHee@yqd#$1oaf{<0unzAE2*eyva+PPG=^2T<@<_a_VwI4Kqs z(PqCFgoDJTHq~4_IMk2x+gQ$&-)u1Rds)%A5}5Z*iby?l5H1W`o&CRk!=vrje#$iC zm8La!cxrw*?F|H$dIa76o}?wmIZKJtDi+GzF_!f8bA_#ECmV{irDoGu(xy%&^|d=5C=VHP&$dcSr=c#zkhEXQ8_*T(DR@F|2TvHi_DIwy*S`}E~fsy zE`%(t{Q3pls;vV-_{$+ufWOW=&A|WKa`%%OzFs33e6q8%Yd2lL4etK6wOjadO(3NN zC09BjmIOfoIy392J%k{@DF*EmwBOO>E{9qjxE@?w(cgcvbpZ3%+L9){58Mzo1~LGg zEIfXf&5O6P4LtFdp zn>SKkpwHE!zYU@QbY)GRh~lkca6cgkoHT(XDX?|8Y7#%Tb#mf`U^kI{t#z?-3H1l# zvsKp-j?9ZPi7A;JaR#t(tOUo;Sa!@B}(s9ByV%TPn z2?&dbe4Czz>yBgB{a~v*&cMoQ2^A^Wcbi;}-`1K+yq?7Q%~g;tdx;bPC$G+nvxX(s zHLbcKYXY^kT4I{z_JSaVIFyl@Gk-ib-@7dCqTmO^%$$e!aWM-A2a^z3Y{6qIG#Hez zYnqO5i zE+ix@9KzA9%{8kE6VJts|7n9=5VXq5&%ebc^f5K{xmD*mF&rF>!##nv)ZM?hdrrWI zgocNQ7Zl_}c0q!IdWP{3&wsgEwFC0N(bs>ElZ7Q$qvKP#1W-l_FUddT*I0-J$=azi z^0b<$FMcSxOmagtkS)ej5%SyJv3GgmmfzKtrbuNP(!I2;c)fu63J!rrn+Cx@q3hJF{o#zrOR z%EQ1DaMyqk0gtDEMYA+u!NEmwg@$#Nz@Z~#{I1ipfuZBkVnRa4OC=d*{{GO?A~+Iq zHtrQgaho0T+D<=^%_HafX4gJ^n1hJ($)kTNCFy|_adZTHDj9U)mRal_!OUw>*lnLwY6d(w5|D*au;N7uM=Wr+(BCzv3-4x+wy8Sd*%-R0o)}B zlE>wGPygQpb=m<9!3U|oUAJtL@1>JJ3V%cNk_-kp+8^5tESa*hq*|M=)}LUirim&P zIj;=)N>=z^Tzx4+TQ*TpC)5!*F~jr8&oS{8TjI|PUXUoleO4g{?n4D;^({>ac!1z3l0 z*o$(jJvTWV#DctOl20J_YaaaIu0%NLgFFiYVR8T&K>y-TV}e8`PgwVFo$;I z)oAB>{;=g?5f2{|%SoC|DZ|5st>>S_MN?2c>F47Gq_Ko5b@tnIh`q%km(TPwEUaTL zp9#>%dj4Kt*gb-23vQFCQuk2%bN#NbkQtwYC;s)jLp@WIlOY{*B0=Hbd>@HLcHm)O zy2ZX=_pLtgPhkPW(ccEx|3`Fg(Ic1cw*Md%lVg)EB7J>*c)o_%$o>ks*86(jnKO{H zBU?P8hVz$AB_+{$(~X)OyyF*d|5Wu8hpk2l$u(+f3=IvnCuqsmg?_OJ89Dj>`tVqH z_lN50d#-p8=JMq|O8BQQIzmN7^?P%IxqgrCZ})50bu)12lC7^=nV7&BnVXw)+dLBD z#Ur7L@iA-fs<;M+#B$AD&nY+JRx> zGjno^PD~iL1tBZZ;X(ZS9%{MB$&439ngr5#AWzWPHtPMBGauQ1Eq7;gp$E10UT|uB zQs{0)NJKCRj>vWUzV@%*;io~asJU6a8x^Uw8r&3Ka9T?85f@iJK*OMcR4MT3lX>+s z3k$)*q*_AMDON=6TW#E&KZ|Xv%C_I`=c2l}>7pOjU%mfAJO5$J!?VQ1NZXAY3`^<{ zV(g=bhPr`$j7ptwYBI};K6izZx3@JA-~gT=hI7}e)#&UJkhE_!72XzJ#hGk0xuSLS zze(B@*I{g{=H|8@?y{ZMtgP9G*f=@DsOeJwXW*n}r=?Y6=xV$mcmLcu>+?sGgKBSO zUr2srs%o=G!(i{-@r;gLFp6bnd)8eYx5i@E=n}Pb5r3W{|5C7AU1*6urI(;bLI@tq zdSr6n@a^Y2d%7N3!x*QF3e>mEbxO;{nCSK7o0Lm|fN@-&=KlgrxLiJqNLQjmt6}(cSJT&$W0MdK`jB-%mY>oF1IJI63bB zhl^A7Ok(jxm**FpxptK#E}8k*Sg8J$DESBvA`FBWIS>eu<>meJKQV480F`iT<|5~7`Jr4dzdJuYq2{|x9y9la80+}ImdIfdb%#Cv&~r+aGu z+JReq8Udk=1ExJ`Vgipaux5tHpWo1cxreY55!SuSh#x9)Om7WmVOQX78qzbWQo934 z_Dp^jPz$r|s`%t~12dWEygdMlIZGi*5j1M=MnWg#@8zu}Y zolYTyoo3s!G}xypum2j0eO{es(@q@}!BsE&;+7wbv!fL8X6Dstc;Yal+KZwHd?!hJ zBSwNWe*3K_%-V(a$Pcors-XCP7SwVU*sum=dNif%Au3weQ=~p#;<`GRt0(v&bG|QI z`U(@11~P=0mDD`xI@?|YLMJhg|B*3!Xckr>VFuu?BQNDdq5vOx^P6Q4BwK-4TcOL- zEfItp`EWyQ|G;QJB)1cIt_H%cS9!dSBlO8oP<~rV;>6J!NJb#4dwMk7Hjc6@bfp!> z-g`P$=04GvhB*p-HBh)gy(^X$iM<_|oQ^ zYlrhBv-)urOTT?XT+yIyaxEqc`4RmCGNM}{%~P@{K^jEXi2bdXSs~Xr(Uvr}BvFJr z?ld`9Tf!_(c{&{E#3lzOxz;(OeraYeu|1R5)JCQGYb=P!w>~VJedvt z3KClAdsPE_8#pL=LR3jXp~KWvp>@d-$1sKwHL_g*8r5K5gB0SiR`rofBPcDTZPk{P z?(YyoLx3X?C62;2O~a8&(tV$gojh#^IbuN4NJ;fD2wXcIHZ8!*yGAE=eRH=QwSj{c z52ZYdu-mJr7@r+V=Vt#mR%(;~GgdTT{0jO%jg>2VQAhqWR+Qy1PR{nA1f}a=BCr0B z?)i516Isr~(cL32Goy2iiYVyR4MOpMYyBdq#W$7#ie4QXtkYYlR4doMUnhO z2Y@Z!10A=)5z_w~oYG1C=$8C)$czA(S-qN}HY2H!v`fz6ctM zj?jFzPEJl>$YGF%d~0$ite?q@{2ChfGf`!~p|x}fgW`s`T*gx88+ zbZNFE9|NzAL#JxJeg92oo)#|;&zASp5)|gfxInaPp|7!tNi31!9NoEdbe$vUoq2K1 z4@*C2{EZz^u8yVo`M+9PD1u0DP)9(mn6Tp2=SME+Q9h-?&%cw>czZ`#^~gY{`@42_ z0|g5wip=NJ&}BrdvN97}u-psWok;+KJi6z8%$43@R~06Lvk@WCB^jsz0<(p6MmMc3xuKd@!{QtQe8$jE6YH~J@aK3 zj0pgY#`AP7$=^?cDt3!3cdTu=q83D&%s?iqgNJ95x*f3iN2=jxVxj9hpDeP!lfuJX zUE|iVw2R6<@mirr(zPmLdS-u91hTb!dz3LGgvS?8(U8Bsc7CuRdbGf}=YppNZE}=$ zp(4lwzJH_p{Cdbi6~|<8)I=no$Eq5QDbP(Bec32}f($-~Xocg3B4k~mY8zsGVJIk| zJyVV4PRuCUbpwR!!G%V&7fLIIc!hWHbwbp(tLW9-G*C{aZold>MbIRd#& zVizhL$?kDyqP6o#%l`UFaVS5d(^~eCmX_eOrql1-*)iXp_>s7?$2+`$NH{(_``Cp_ z%xyX8aZ~bNV2DCpqdc8bg2SYVX`7`~o~s*ZbqZM-U02b{qw=@%x4cM+Fxb*7GOw9( zIC4!uAhIF@#SB7#(r1#<&O5!t!RJgi;zzjDovY_sHgg+McEx>bE2^VNt>N?aty?J; zH&-|EFdtITf#mpB=M^L-KtC%uJltt>Hl$WM2J4x%d&I)s9Z_Xv z!Ps9w<4LS<06Bj|b#To?ZC|Ru{ItvK?HO6hCv#|m6I$6DUv{y_PYVj8-btF6s+|OY zCj2})4NML)*sGxu&u3cElaSc+(RM5Q`M;*nc-=1ouN2Dh{PbS@Y5&1KLCo8O2#U`C zUg8qvpq_SneG*uv-71fX$-YQ;YwMh%?}?Hbat(e<(_x7?91c+pk~F(p(umm(v_Av^ z?EJ*MfqrUjz1Z=r0Z|QU`_0gkFuWi6QR1WT8ecC@ySZ@Ck6PMfo)`Z-F@{6CvWBDm zU%p;sd>Bbq@ZicHr3W>f$*(Vy)Sh6I6Cf)PEI1yLdm&gAbsE;&Dc zTrhVYHPqT_Gu!@E;{BNK0Z7EZm3R;HgMfA77S>Wnos+V>{7z+1Po+*tM@GsQVxR8R z-mfkb=EF<6jdOz9U7-Lin%O_EtI*D7_1NdyMY7OS}*M^T);j zrLj^@1aU7cu={COK3}Om1ZX}E--Qx#ouJ18iJfZL3zsHKW++=2+SBn*cP!c~i56?TNQFB01Oix*@W4oly z-V(5b7KWMA(*l(EMON8o0S0PfdU`{kR&0(N*&6yf=A&J9e_JiA{uBQV?L<*m7i@*I z@a2m%G*CxbgLfoL!(H%X#HC6UQzyc1hk&J)xFn3 zMy{3q{AF`^4P-->39E`S3Ziz))yZ{%d>am1%Wo*t4i+gcp1ZQ!D9xbcWgJR1-qU9! z>OTLo`pBEtZ(cj7XNezdukA2Y^*Lp^mo3%akow4Hu}3j4=w@5*X^UAap?X@svqUd$ z@Ad0XLv5|q1sa-$`g)sz3X`ts7K!(ZaaRQUP=bBpAOKjFp4e?&<-H)#;@hZk(^=oJ z%0Uu@1t4Y~8yh>6;z=ngxUsrAKZ@R|I`FvTwAijm?f{cg9_KhG>I@$|57@s|b1wq;LHwf)x>|`(? zPff5qH#76axAZ{>3c@QLTiNhw>50D=4G6dGB087;`i%MPwZz&gXk@C3BMo-e28)H+ z?`{7|;T4b0oQYrLKb`Hn{!2yRhv~LwSC1vW)d1y!8%!&dfL6k6ty=l|`dTzju}AS! zgRYl?j7-Pe<7Zz$0j1bxMELr3i8NI-@16nUBiO{*oJo69hA<>4(8TQcycqOVkH>2a zc6Q2kUCati9#_;BW5|jvdn~s_y2f_?h%)|!O1Y5#R(ouhPc{BELDbB9zYfLw4Va?; zNs9|#_HcJ^IdG}sJNoSH>HZPncc-uBoMQU~`jJpVdoJPE`;R>Wr&Hoeljjo)RB1O( z&<#&3te>#y&sJ+y@IU(`!@ZPSmMz}a@4Y;@@+@r(U(tkRQk9X{UEB- z%#E(Z%{W88vkS*Q4igIZ!G5UJx{H=OIp8G4l_JlXy!3j}xe3QMg)ornDq8kGVbrqM znH!q^iz3e#Dzad3gS>E!a6~uK=m5Ca+Qm;JT79(u`a*t&?4ks*sq5!kmj3)%#zz@h z91I$yN^h9Izz*(t-WWWxw;d&19<*$J#iHl45w)@X>ueMEWh2>-hL+ z{Ql?eFrWT*W5_0YS`LSG(mV5ed3&>niQ9ws8870T)lIG8;=0g&I^ve!Y#$P1Q0`B! z;dTASou@;$KT3=SWcfBVJsqYo*Nzn&p(!&)Dl&?oIbb4(Oj)Usb|e`;Y;ohKzq*)I zK1708Fb<23Hd@h@a-JSO(em@y3;aC2)phX6>{nKRESr!3(5I{j|N4K?8V!axV(TO+4`fwdE)BcBl=|~$dL4_| z&2*DAv#bJ#Rfn}6OXsh{J<+jfbbMh78kHRu>KUA9_UYQvGSyfG|K5mx++qj(ALhG!_Ts(v@)sm+otWlFpQZH0>Fh=Cb=xWjMIJ*H2cxbuKO~N%eBmL=cs(9bwash$UmiTa|gegy*?7;~HqB`J;h~u-K66(H`b5mSDD}FMW{MPff>svbb zNZ*~%`o!a~J+$A#7cngy+_f7I9e%5gTu}MI@Ox~u(0gL#p@@B^_uIp9*)vx4jK){n z^DaA!5>DdUnYc2i)!~*IX(w#Y*AK(Rx9EruOp4ht?K#pC@A#EUN65V}3Wv7aYYqP{ zI+Z6`{_ifD-SOc<%>=_LTBDV$+o*W|gK{5{mEv@dKC+bv)J3L(2o$t2;>*uWUmNuH z7I*6T{O>_k{}T&<^5Wg^Y4a7rw{ez}t6YCg2J?o$XsN$$Zp?Zr`TSIKQ)BFYORskW zSChf}aVmAVRaPw4zqQ4%LJP;HdQ zKK@y&^GwF2{xW#G`|o3G;!Hft8?^Aj5n+O6v}W~(e~ny<)gZaVohDok-va-f%#$!Z z?Dysp_zq~05ER*^f!5js?X9=?!vo}zaS401gS8G?N5VFZv41Dmt;@56DGq!1HK)!2 zNz^xWEog7K-UCM03k8hCL9U(k%*^J>U3(cE+Y<>AMSWqH>372#zupj+nkSi>FGLt! zPu&*E9%(*&LSPXLIp}xd6Hr$p%J$lY;0Z?#pKv{QY%P75saihA(TJMZpd4+4hcn}$ zcP8_Wnx2aaH-5{a@$bXThH45Oq7a1zKSUJ|$LQ^b*5UUb6(Uh&Uo@Uu7Zi`|2Od7( zm~gdt{z!9fWHHv2*xWCfWE9)P=bRF_btJ!N`ABEM#qrpjJBha`eh?%gNNnWw@E5|;PKiY3$-}roXF&BJO&Ym?e#Pc z<06eaca3+!;@H1_4bE+P7AFm>gjx3LWZsV}|61#BJ@zJi+I4Lixj#=m;6s|J+*u@X z=bleb%nj!FyifNpWGlVK!?f1<$<5g~MC|g(ZFfFW%H>tN#$Qe73t?C2qfF;j71&l4 zWdq;w!+j_J-ItMhw15(sovh_A6@<6!$#6-E9~n9Gz#=-U>);XRkkdT@MsyAZs?_Rs zk*yV$0umXvk63I6J-@~21+&IQ6dgZo6_fW$t-+|{?v?w$AMRD~6@Kq9z+wM(WGb6)#*kH zlh2XbpF~$4qxKD!zJ>cu{&v)Qij-o$xZgL|T-#y+H^N(PFIaE(7NVipbgU|bsR}i= z>grszW(TVNhO<<}mUwjKhelS@5*Oj-;DpRj)N#kc(n9YXEjbI9x%vtA(u8h#!7S># z&r~tN2r#Q+11B<$Z20chj&$8XiwNLGzJD--Us3z< zFRw;omqb10#_@Lx(brS{%abCWT@Xy`=}mG;3QEs56`ohj3Rm@`in3+iE&E%>Hw5LH z2=8FgjciS`7TIjmVn4%t(~OKURJE{5*WPX;ggi!kU})gX;^=TVvifFDDwCZQacRCY zIh{SlRWlrS1$Q+^SZHu)Xuxu8dZAl9#Hu8*SAK?O*xNBikko?eYEpEo=F~yi%9qyK z{h|q{F+~N)==)J|(!GnxaO?UW8hQV4vYjUGf=pkAdVU?(uz3|&_=f8GueOPasFQnT zEBz6Ar(!Us&!@NyeTybFHv}o8zO@;l!a^dl3niTNbyamWlaT&Y*ZHcOyGF_{aSS7p6OZ6WPB@A5>+|wWr8QYtW&u`DPmyxD+az4yNQh&q z^>gnkwc`r9urP8lmN3;DW_H8TVXZhyq<^URg{IXsdyqWskb-A9At`t}zmSvXQDA|N zj9h*I%_*?MD9TX6I>Xq6BBXbpzNIP~gOb+eTzgu_H8Lm7yY2$3lLL>XNl>427fNhY z3++d$w~LSR#llzJ>&@0s)h%%{M$59d?YfIuHhd+MO2W`ZXKawG9*bY>Z3fysX zFZ;=)(;qwu3$xFtP0SpEVWU8k9Qij}^s>=l1dGJ`-Pb?H6b<*VlNyYVY6oAb)w@{f zia%OQhk+>ffQgk;&n%3s@+i~lYJPr|<-4y7G_X*bEU$5`-8cFU+l*{iZfq}yRdL>n z1(`0HOr1`q)vyMaoy8t${(~P6mrn6&*)UZK+M4;)U}&(%w>`>)MJ&Iy8Ga7YL?P|9 zU)s(s#dz8N9*4ZluZy_6FgX~W3)?2NwI{t*FA$Ye!;~(y`8T<1N0rq#u?`wbOo|_c3Nr% zvUYcm7N^G+I_cd8eHyvS+anTs5+)sZa^mvQojb?vdg>i)ukN;^t)Z^HoiePA76XM* zP*8C^pw*J0bu7)hK1C|nHTE079)BKVT6l8uIRB8+5{JDNG6*w<-k42$nyW{TD!;q! zRw2upmY;7vl83pNBl>h%E-dhEiCg&+r^9W=}1N%gNbg zeZ{F5?K&JfWS_YcWDs5cq;1Q}Rn{gp76}N9x5Jf3jkvXJm9(&KDVj71EY63dIDWkWtz3*b#3rNEOc6a&v2^0+vtabQ9@@G|BpJ>SfqL1K->cyp~&r zZ3`8HuWc!OdTP-kY&mxd*)M5N|4QtG$Bzuvrn$dShIcf+q1x%#svnSN_pmOMt2vW! zxRqW^F{#au_m>m$eDdI&S^@lbdPv}ofMlY&nyT)%`CmqT6rQSS76Z5Cap!*fSsmI& zQbG?ys%5p6E?+ye!cKF$MrnS^nIdrx@6J;B5{1$@Eyw+DpH8dwcAnxoQt&+st#r76 z-!#D4UUNMa{7D1r{m=SYS#wzEuSgiyxPOvfuWxP5Aq+*f_RqZgGD;u&%i!qqCogv5 z|JhW+MOl}x&a=2)xSb8Y%bDDq*`pU89Nzd5Y;fVI<6j~!;Oq}?JVo1|E18c;AJ(Xr zf4wJ4;$j7MYbpusSl z%yHrVXPjyHYdU+D*mA%-e1!#FZ>Xi}&~!$S3;39`xN5PKXTOwzh4&M~AlRkP|2Z;G zd<<>cf@ay^q7UFs6lZ;%bv`uQIrNZc6t-PoaKVv+eS_GI`6mVsI~M*mY$fY6Pk}7z zn{X^f%K7!Tk62dM3wLf18(p0QDcTbosF6kDs+HcCFwg0FVf8-;iY2_#6=&JuqSu+! z+r31WgKX>Up_lN1B&N@2&%)9~KM%b}8eYWkO4mcMrITnH*7H}% zXN&(`74d3BH&}zCBk-BW>BU|N9NT$6Vs9vqz;Ca`o=pfo%)*%;KdLhky`%D zhF7@~xktky!dP56Zv~;UySh(#mvg1vY(0E;B}zV|sO$XgdM%X#_^xS1dMR1f!!0Fg zFzdqj4!-E@M?Iwc9!lWAV~`a0NdMxY8LIB6R!^|j<(~8}_MbcXjS82i2yNO#QPm_( z?A45Y4`=hv3X9zT;pP=CJn1trsHpqHHQS zha45?`)4G@OYK>C;2oYk?D`9H?O8U^*`*A^rQmYf%g{PRJ>!I-Mvf&umeRDRcNQ&aqIB!atW-LYn){VH@Yf zeB;%2@E|OuBd_l4)Pn)dL-3Eo7Tcj8dw z{sUX6l?+zSL*#AIxBlDw*$kZp#@Y+t_|d}#lU(UBhXd&k7tBx`md?etPnk3%w+Ur0 z!U`6(yqB(*gOr1=rpGrnb{1|yqg+jzCWFlKfuSKGkwFwn*xkKs(;Y>!<=#uWdWLc( zSFdZVrA`JnpT$UQAcXA9^muo&@u!OvR=*5Lb3$^5!RCYgo9pl>qV8}KBb2ZFzx`_n zV+I&8=F9-P{C%)Ue5G|p`aC(3<>{LbmA@AjcFAXulehf0BldQ7?ega*X}%l`z-U6u zJ`~oEI`gMR=3UbGumiESFEsQgxti0S~zi)(-pjfGILOh|JnLf_b!vp@_r18%B zfh6hK^M8=hk7X2qyBP5uWDXGsiT9J?-QnH7+$m9K`b-$jlZYlg8TdnwJjv!xc}3H0uYgCbN!R_rBC&IDI4_aWGann$v3il%^xB2+Ps?Im z(!Z0Fm#&<@X3KMj9b0PCtVanaK8d#(t%zS4|;?GZd zdocQ;V)Z_?KWTANb&!3o{;?7dXCiu;@31km3Q6)B+~DgUX;B#U@H_W9fa=Fso8U5r z-TB04VBJ*rk-bf1h@5px;M+W zrKP3I7l;>SjK>oQs|BT{Vc8%hji?^a=y_`~HwUcAxdVgG z$!Gs8(Lm$x#J`tIYfnlJ2}=o3l5Z;87&$LdhC-F2$}1|$?uv*A-4*U18em;(AwK*! z5)jG;+Se*Wi^`*PZN?n@E|a3cn;>XXW7d zt&$0?1C=~LF7EZ~*FS#zNKStGuT##~^!C;RBC@8Yrm>@AV4$(CZlI^GuC?_C*IG#> zaal(s4NlNG5pN%GxPikYJ#_K@Klqj4K%;B{RSksLB4nY)X>3$fqLH$)vbMIix;lp| zam#&q9hSTPfdL`NkO>J3i-?q$-G#EB@Rvpdttl97)6E8N49Up4=V)o?XaRvb;Lu0> zM(s6BqYsuwTzEEU=k5w5n|%!=QO}c5WhZfW&%3}kf7;lHdn?A%1+9S zjVdrqOrAfV=KzrqM^{H@XM1}W7Y8t(OMweG_4G1ab)S?G*52;7Z%=HUC@*&xV$;xc zQEXI{Nl3W*;K+Vn9#?^S2Ibq65l|bvZ1d;O)aTf@u&z~A0r}RDK)Bdja|Uh_9HMLR zB{Cr)G&Is@N$4fQ7xO0xVX{Aq{FxLQ0oWWz7Vae*<0D6W;FbUcxuZ!OW!l338R9Fr zEd&co1FQ=;8=E_t8fqBK#HI>V$qk@CKq(+yNmLfHHyW9GtM?>NKmzJ#1Jo7F&y1Bi-PoCWLKTgu57bSF@u)7ZgjLt(d|oR9r2O9Iq*+38clVb zn_HYqOl9=x!=VmjH1+C9eoC?i8O*}rB`eqFl?#0C`RyZMS74IC67#_aY;+Gv5xRpo`M_!7h z);j;^8uzWOm{Y)tT(bIcocLu)68|3EYbJQ0M*PG5twVq8f4el? z;uIGmax%Ac444Cb^8W97-S&F=CUZ$lU8a9WLnd6V5Ng%>=I_0^(+ho_#q*ks&A1JT z0jQwmHxU;oD8sYb)Z8qms7Tn@0P*Z_)WtQhKSs(d!uW;&3`H9V7F5sW2X*Fv>;fP< z{EQ5b{IK+c9jZzD_vd&KVCbhs8&-Jf0NHVD)SGSR+NaBcfco_NE|-1A1v`rG9fHcL zU7&d(7Q1Bu@@P|eq|m+Rg~y**2%Lnu}Z6 z6<%ANpj?`0mE0Tq`uY}oYHq^v$;T>rLqtSm-sSvZ zK(36G?WC-NocC6Q)){&+^?gg=fdKFYbkiD!2OKM$v8D-w$~xJaNa4+)2No{9Glfm3 zA{iMO2?PQdRlHWVwhZUaS+1Tl&XC3GzY&DSlaf~Ec?A05e;WM5J+7yzH#RmZiS2b) z+E&rueUikwz}5wDMF#K5jwYK6e;A$wJUu_Fs6V`aj3jjcHeCnJGh}4jvu}b%50>|U zIK;w|9wfGT`{v#Lb^agsBL>S)+QR7k_&Mq6dm3g;hOcZ$<^J?#{DPqQa7M@}wyf&> zueBAf3pV>Rxp`8bt_&>w^`|~Z{rc#e%F4<~E*SqwbWf`rWQWSFqFhR;_NJz%tAy|h zDSgLu+&n!6t=ogc;r36iACG7P-2e7&U>n8g?qYf2ySGqBm}U&}mIaOsN;CdDo)Jhc%)+qltvT)%5M8nFO36F)1 zj*;=}Cpuk=rMuL{E&!iU($yV|VAld5A1HRcc<}yL9QU^(+tz{Uf$DmE5<|Kxilu{|hT3mLZB-Y)BI>y>9O98{6Hj-s047 zhQ2RH*I@O*{@v|{FD&)4g&6ux1vQ!H>XX2F6nHTZ7aW{aDARnAljS4B80G-6ccT|3 z-Nl2l=b+C3Vpqbx^Z53MDN>(t11SJ`Ye|!fx_Y%;X}jw~O2eDiuh+c-$Y8m{ZaKFp zS%HM&W`1kpA{Og^`s(VU-zn&iTQg??fW6wu&Mj9cj@+87Ay7sZR#A;Z=b?89A*N$) zSUe3gIw@M<$&BWAQFC5RE5V05jm^y>cFXrOMHGq71z(cy0pJEptojrarH>)1XJ@xH zgn(+g5aR!i3ahpF+dd2Z_IaD{Y{pf{~=Lp^d@9-dOC}o+*iM(=%D%t`9)3+ z_i$FfyPmkk@F*jXsU9sD9PD1zV8TwZYo2y{{wF4}H(VZUe(#-)AYYf=$*6CDuLQn5 z?O*mwa+MGie6 zMw*+4$}GB;Rc;vi;3EJ4EUQ6|c&TD1mF7UU`ssGEgo`382~Up_z@rbC#a#dXIN_~1 z3e|qTPc&Tg!6X59U8neBdI-`_uFSD&St7kqsv8B?H#RW>+t-6##rTg8EoT4P%<)?n*dqfQiYpARSeP^1^Z&-bvyA_gWmKR@6HG*zaRAS}F9`x6LyByr5ty6!;W)(b?V5Ai^RI zg7JmUL%n(-u1jDt#SQE1XpPPsYz%La&y0B<>~;Aj^m2exhD7ggM=HcNSG<1O5n4CcF#L&* ze|vV1(s{d`(r58I4GVg?Zc!(@Zy4<~88}e5O_*ah=oj_qHpeOe3t`$<&?qK;YeoKs z;g7G3^d?oStL#c*Jgq}RdOJw7)&%bbY$=yv*&y%BP*f^57Mr zmYIo(UKl`{TF=je7CCzq(#v8sJd`3yqd9U76FoCM&CDs}v1ks`+?heV8`Wsi>V7rFMJ+GFgo*+theUUU$M!0aW#A2r_iywQ{ z?Y{1p`1rlO*9`JnA35Z^2j%X_?v%SP^d=Z>JT3%dWotbwnyGCCvb{lcLaPhhm`=m8 z$p)qqC!}U~$Ph0RNZBH~(0Z<@tdaE+fZEryNkzi~SM2-A#oj*pprz8mCSqZHe6`Lj zQP8scv2KBP!{@>o+49A|lbKk^=Qt3zpJYbLl-KC?73d~+mmePb$)8wvsH z_Zk73Qp9Eb5|$|nsn1oe3;Pa%UL;kAfgw?-(A3k-EgYW@6_(>gtx6u?=clsEje3wq zm>di+vRk*GMgmr#==t6|JS($U?K8!1R#+z5w7p%&r4y3Rq=PzSz6@@!<9N;gT(cm6 zw!aoqvv`6XjPsNQttqfMK;Q6bi;~spq2Zo7=iz>qk9whE2_DlHSav?P_1;)DZ*Ol3 z&pfDzV`0Q_2V_7hoEL~&(~sung-?B>UzRbaIzx-Cd~JFP(poJ+lOyP~$Dz) z%0YW}Zfc4NA6@RX6)J|0RmAu+aQVK40^{7;ManUsD!fsX!k&cPEK!lUtq)VF{1l?d z*SCLrZ;WW|ll?9J(IC$20|@Gv0kECMe0E;^`cw0by)jO{@38-5DmSX$ULcrY!3rqB z4y6u!>F@uh5UZ)p1~%XW{0J^qs&K%JnUaYf_v zksxvR)#v5g-nly_@%!wU)z0P%NcDFLv4V14U}4r?WyosNgd&3Gov9zX8+}H$jYea( zBU@+PfMWytCg)~SAm7jZS&y17+$9i$|^T&=Dm^i$K3 zclzHC6ai=bhx5H9Y)yp5)8(+ZxGQt2){KufY)0^vvly!xuI(I6ohhg7&fX-SJ&?G2 zT*#u7a9fgR+N`zLCY*4?FT()!nXvdnGO&rksjjv*1!PY;#Tjf9$(q4@)h1<=Ahn;G zkLXbPSnf3?o9R0O)so6s5`X{t6&$&a>#>5}H^L(T47h{kk&UU3*2<##l};+2gjYLD zeZE6GIICPy&*?>-iSFk@PBGGrXU_p*Jxwz^Z2*c5eo4t;168~iUE4O9rPptKx?`8M z7~VU`ZY?hHULaS`D)G9Q%WnVsjvKB^15l1l4-c01ZLSKR4`7L$O_74dDx$Ep+_&|U z=XN8kY;szi;(L>7j5Rgf=v9ZzH?J3f&(PqyHf?33YHbQm9s}Q#r2U1S#7wnFeQ_hibc=P+}|q7<{bv#1E3E&@yN_D&=1bU`%y*A-?~oWkQ@>dquSa8llkn>! zTLKlg8O3-8ujL1VQf|i)zZwSL$b#!R%^AyXs6@*6^BZSu(VGCkweYPMMkk{8=NE=t zad+*OUlIl)>Oawm-kA+4dBKL$F^o?&OSNjNDAOFj85$pN0AO+bT?|H*g^4MuLKoZ! zPlig2$->1qfkpZ{Ivd9?m~FLoXha4!i#DJJHki4{P2Tm|Tn7$Bxm816xl!v##S5!O z47e0r1|?2&1!=GuJ(CKe1?LyLZ08tBf_V9^m?Q#o{dyB7Qhn(AvjzwV5}4{$RyH<# z0b2cWz*=9jPLheHzM(-}aBd58u&?I{Ra9()aH1ez#c=-8&FVIt|$HEKMlSe zgU{|)7g>v*f~iTGbSG?QTF!$7VV7CA^?sE-E9JZKp3S=LC)%?-ZD+94^P0Ue7k{Sa zj@+Z`wq0&&M#@Lun*xnAv$ps>_HpD5-`%f{H6L|z6LzMC3(eZR_Id=@j~_#P2`eVK z04}9$F89QVm5UF|INlenuiB_2-7rXRzy#smhhgZqUo^DFcUprPQx(VC*ODuIWp!RA zy<3N=S>O%~d*-!kfeFug6#$ZMM#UQPT)XDX}@e{m+witZf z&Jvp2C>nvtR%=;`)cbmJo8tJ_v**uG;tb27p2h1oOSHWcQWkqMO%7|*6xac%BN>0e zWwD=8K}1N%d$Wc_GXfA{`#wH{g&^GIprNYMmuJ+KfRDhJ4QtB}4o*FN_6!s{N9>h8 z4pq1>#3C_Y_tH(+Bf<36$oqjnX#Rq63xR83x~=P%LwRjx&Tn+)on;>gi;2GbnFl4% zUR?<KZ<(hpPBg{$NRt-ITD z%9q_A&%HGrot)x;J~NdQ92x`l?N)#flzd%?l5A=Uiyg@`5V3IC`Hao9xG=V@L#m>$ zUun_BjAc@Yr2~ufPY*Ixpn9 z)ZwB7Zod;09k3`F*`u7zNpftIb#Y;Rn6+?=E%c!3C>R;c4p6{C$I(>o0J>4-FEf$H zBI?P$8krez43TvFb-<;&PpWIZ8S?CkOViQxbh_I$_L`c#>l>!`+V@|Dq)1a-6`uSm zhV~J6_}Oow`;jqdM6>{0+MzQ06fr&et%;z*8QhsxQK%jo|BBhcafw!&SEzjaYfE;+ z@{F*Dc(JR^griqge@@E+*yf&NkPc*oFoqZIPwOkb( z$dmrU(+^RoCZoSFF2uPE*jhZ?+?+U9bg~W7O9+dvj_m{nHj5aCCMJsSp=mud&8u5` ze5t`VrnYEL`bt=Y&>;K0y~|KxQ1tToOX|HbV7}86KT2O;Kax{N82?F7ZnPVmK)3zU zwXtY|i_TE%-h4B7)#3VLa7Q4M$Z{bG_wLNv8u6IJ-PULnnu zK#VG&%}Ol-G%DZ)hy`O}L?NQlezq3IXzFBJDR7+V4;D9x=AqotW5?1lBDDapovb}W z>!9SiWjoWh<2KDAJVOmN97}A5gxzL?DJ~1nS|>zBY4yrniudH=G$@g8d>9VP?Lb3= zbd0FeW_!Q=v$i;)6zWGbb-HOm6of03YOt(@SE#UgHn=fYiho_7lPg(f1l$X zxUWS;cSw}p2V^BtKX#r1o)QrA#8}=Z1PMGQBDJhhEj^V9VmgME&+VhQ3@qREaImp`{P1Cm{d#g7`7B7^ z$nM8+`}#I9EoI_9_5X5_9`%EC0f=ymWA zB9qO!H3I`%;FDez@=%{dPx$Zd?<}R|!Kz6rEuDUWeQE!umx!?N&6}y+mj0BN^SpIB zfX$X?u;V%#1fm;}8ln0p#R=LD4xJ6W;vI&UAh>e&tct@F2@WHE>sJ4Ht*jjVs@1MG z@g65{c^MhjgNhsrTLo}Wly!XZtE=0)g>4C5+pO5PzLL8dy(h+~6Fv8LPKeK{r5+I} zEr|V+TpZgv)HT}L(!A%6)wPguaUp=ACulpM->RADwQdiuqF@9$B-ajncGgwaiT-B5 zeSVj|%J0Y%aPH#85{T27vE0jUYXU}~`NTHGg{!4+{rqyYt13N9aZ|`?@T;Mk;_qFz zZ>uQe?m$}iex^TD9BRyxnI3UAxHq%^b@NH1NA? z5V`YNwRrnUI>l#iyE%&MGOx$SO!8xh`y>z2vX~X()aUK+6N$7&^FbS$b$typ@fNmA z{UzSNXL(HMUlKfKze++-bl2GU%{Ill`|oAlzfcr4hS$mkdVBZfZRwbGlTw(Rgm_kLO^|4bcXJ6bB(us)B|$91%%Pia zJ}S5dG(Tr9LVhuSE6EsW%EQXu@mg{X6F-(#h*R)?O9dWmiu9+^5|g22<63kQ(dT||unvv+O?GMxkXBPiJ zzkx3*y4T%RFMK8ED#)fG4N-K|)M3P$YwC;B({xv^s3HwTjrM$cIWx=3?%rXWpKiGf z=^C(RM@L6NgBD^QxlaYRH#axeXj;=#QvuUqXqZ4`i7I1W{Xk8dnV5i73#)*DR9X`r zIL9k1d0M3jE^W1ahQ2S}^cpam6@3qkdc7GI>1td_rJ2lW0XA5$Ve(JX!Cx5w5GNgFK2#X$q8ja&S;T z%%kF9Er#g*?Y3DifaHtdXJ}g2x`U4kMRMX=3b>B1;41*B3>k+QvftFzHuG2l2u$~fw?5f8y`4m0%zSSJ)9H4+|v-l7r42$a1u`^*OS z%z6i~SvjI%H8MvG`VYA-x;_2f4|zSljIsNUT4S)Tg7h4A2;=)V`7LmZJDZ>>kd?c# ztds!rO1d|UC(pkPYK_zogqUm$V>&o*$XyPy;Y^dcwer0UtatiIUo*t{6D(x z0jBwhF|gUAxO9Z~qgUVp;4@pwo}!>IKs#Ju1sH%f)oZdR?!)>9>6kTKJ^nK2^=a3{ z*w}!jY`oKp-6%zm-7AK7zjUH60}+`8jR(@L(E|9~2^OVYe4mk7TLRi;FGe=a!QS4Y zKj}f>Mb4p8tG-M%vlE}+!H`x4h~Sd}C>OS}`G*mV+3S7~=-15d0SP7t8hF042B4j@ zC;{@V3ZIy9^?%Rw^#Aj3MPzz%RKs*pjJwAQ@g0%M+Bfu*Tn*#&B0h_~xsMqi?SRq> z#K2sIHnkwcza;&clo6EeQLYPIu0m;l!ZNfWrhsu@i~RGD;ua!NS}kn!uM6cpWtjWr z&6(3Vw=&M3KmW6#ft>LiHTAKhN1KcWIy!i6-&W@J#%>o|EXh+#$X^nFu;nieb5MGJ z^M^r~v*go|5*Swn)aUbV;3FmX54l!jP>v zhyJxqhjemhz?zt@PZA(HtCGO!rmHRS-|E0%Ri|<;?S>R!Mxz30U7! zNRr_A3nwDxiAC~S<3%Li=hHzf-k3?N%zLNAbo}=~p=qxC0Grp?x_$#$M{>3Fv0LPc zXR1?k_%eP+i#Ck5=EYs`^4|NcDWA5xy?{*g5>_K!0n00hR`lB>8R!z-b(<~i@9%g0 zhJFE36xlyE6xy6~s5r^I_+A!V+UWX=#qI&2JVL+9x^5LJGqpHnvh!JVrUocc+zN|0 z8PRany*qC>>1nxTPfjr|z1TtP)%=@_ClJ$rak%~28$pr!IM@YO-pWY42md^(;wYq; zJ)c6V84>U-%}iaMQCu7lHe4WNS~oz9O#q_d;Dws18Z|Zb+UhFI<2P?!$Hm7hXr-sZ z$_}7Re$Jkn(XUdU=(dO3iHki%ikbKUiz;;YR=@VfOMpaxUTDkjrq$);jI(s}v$N*m z{`{F)i#94-f*5e@nFW~PVI3Wf4MTbrO!3A=8_yowy+@%uHLm6cTmTtE&|U@+X21-+ zd*`R_z0|yF(WzNiqQcr~mR`V3P}J9Va0o@Aq#@9wcHXqQFR%ug#-U+x>ZG7ttpFd6 zWEe4RT{QjN?(*%VBka-V#DZ3{WXT2brA&a-=6MY*4SLWo1}c~s86W8Or@JN+9fY|~ zP<;NDLjk%7c_S#F?X8)VhR?F(u(*RX0N?(c8Hh67;Pmp?<@HV6(j$fVT9(dg>$)q1 z4Q}|EgFgvYUn>6wcI=?OThNc{hV;j{?fTfxRam8S3=4HhzgyNdNl{$R9m_Y~YMP?q zHVoHv^ZGS`tXy}{={tJy;zf|PaBl2mBAT)s_6`mnAPT^u5O@B{`R=cd(u_YJiZaCP z)#U_?adWa}X3F{K7r2ZJrl#I(3H_7wC*+3oXW#8;Rg4VCEubMeeu}Xw@nGZ)mVu-E z$4p_>raQJx;(eR=UJe94wkvJ7F{N}T7japYTaZ(R6p$f`Fe9S7kpox~{PNdY28KJS z1^#&0x*=PtSNXgV0;v{}FuOqE;6|Ic;P)JbCQFrf8n>`XFJeoDg@ibD3udG63u&%m zV7GwTq$G|nf}m`gVN}NNbQ#j`kjC2(b2B3CC{vk8)I_r4kBoU)%>#PnEtM^X{{;vh zY=9%UKeARKq)d^AW)>1k@5K<2y=v0c@_tpl?7>kWq=(|3TYZhGn_6?V=9` z79j#E(k)0xgOt*ZbPCelDJ`OOmqNFybJbfzu*JbEm8UldRWis-B#Uy#4F<3fSW@I zQ~ujBRrc%PyAh>&@LhB04zUzIuZs2S_nLpwiq&n9zAVLsNwmI$n4SG)r8?G z7g8oBIfLT@XZLfv%>sB=9Nn8v(=M<=BO>-za0~ExT$ByoffY#hU=FM4`18xLcQCgY zpy_CD53fZf|3dz*vT_vUJ$G5!@7=vy5}kAJE^|Aagmq^t?SQtea*cip#0?@ipNlT7 zi#Xc>a#P~LK?!Q?#_TZV=p%Ip4&R$o-HQUSze4RT|C0Y z%_(*hxcX|0%2TV2y^JpimNwcHpc*jVuVR{neuDc^T&8?>;&A>1&=h_%(p{&`5yVkU zD)|tU$j)<^LvP1gjroYpc_U8qaO2!+`vLfoaBD_q&chqjo28S{(IHW)>Iyg&E}MA- zo-q*7rL_rbltA=TPm0t&@5$KKzPl}N=4I|GV&v|Xme17t4C#DHe&x?l@iP+ijA5Yj5-NcM9ajegGUqI*zN-viB!o zudt$>ot?3q&Ov$W+|k#8&R)l^x3i7e{P8~Rm-5fg-CX9HBtf&Cl9DofcGkbaT?Kps z@q+4Vov{N|7Lz-~=@JQN9sS)ZN*n`xpCbkk(Y6Rf|W{eN5ol@2BS# zcbNsoRp%%R0{BhS4C5)HnEjAn3qebpyoGfoC3~&mlyzqZtGx}v@(L>CGFDx&M@vyw zwsK@Ij)4lvE0HKpZxN0v`Z z3xG&jye@=p6ytqA`JTtME7IIT(9-4s#{#^n0Hz;|P<$`*jmf0TG$hqg%ZA4Daq5jH zO|4<|pVaj%n|xzj-!~=nzs!IO9w8zkkvfq;K}l(#tNZ#@+C;*RBlHaUNFLxsmX}A% zlBoCOex1+aRx%B@Y-jLzRB~L#jkobX$*E~YMTQDA9p=I0udt*9WxP`Dz>K|4R9QxA zV|}cA_YnuCl8x@1(M_$PZKr8>vfX=tMCjBK{&F?N8baeS}2$d%IU7l~ctCg9N_j_*tSzI|dFCjEO z$9NJ&JPw`xeFujX%|`~0n%`d+eGWi820nggo%{_^8%&1Zo$>104c(tkr4EwL7a`Ur zYtR@KF;_D4=Z^q2HAZNt=k?ghz$!Hj4K##Cf(GgnW+EoI8!kTY->MA%UCh}t(i)@c zKJ9^|NSg;7ICXjX5~E#;B%ROKel*^}biZ78u+nH`cxK-yJ+l`4@somINjKYc=GNpyX3 zp9#svStg1gYf%_YT&7Fz8YPwbS>FRhnfa6ukCKJ4c9Fv&!C5An(#mqCCOTZaO}x3w z(j62M#2I|8HWAx8&5{QP>D+roWknjNhpLPWTctw_nN}t70yi2fNk=U<_psJO-k%QVLB{ift6u|#eQ=HgzbmaBa z>TMn?&uw~}AW7R@%*%PXltt%vPQ60?WzX3z-Hh$dpA%Eb#BC*_pb}%c$(`Mp>-{zI z@s)*46fsuyjE$B3@%r#oi|v-ERkMJ8RIJm;7{qRdr#NoU-n>i(K6^{14dQEd_ULfZ zL4g316mHbRhgZJ8_nj#Z>`qKZyEQ6RC!UXv^!k3maXP^|zOfWQXs|UUitq>v>)SKx zS1UKZiO`zSnkTjOjG|GcakF`a{I6#cZXIb=6k*Gc#>D=iu|wf~QoLey4g6?}8n<5-z8#{IsGK zsJBwciCj1?VV87e6ySeJtVvCc*%bKbd)G>2j>$Rx6INpNN|%p4Q+S+CC^0?|2Yq^w z$6o(7DwY|{lZBnamRA`xSq!8l#7HLSCe$a4*QpyA&0a?~0i3zhO;1I|feW6@z<^8U zS!kNu4kF+bONVGM+6qz@mQoB-u9m$I-sm@!9xuvVS(Fd|2_UCp|upU5%Jwu;1^lGXxxvnfY_tt&sg@4rlyb zdV)%^zZLLWQBe^dGk6h?9w{$~H=`Iu_;Nen-HgdTUwC6|wCo=Ika1EsBtiG#;v(#I zFY}g)0%ZeE&ZJ!y78d7`Lz4ayZ|0q!f0z3VtPI0_QIU|)=LmNkxh^Cj-=WHGeKgNR z31cP+a;8JOLSgT)N=LCe4e?aBvm?qNFUd(%JKzvSbAB zEBxSSxGzu;BqXGnO=yrp3*=hFTLS~()$SjRs3P(L&vrb^=p5?5>o<=+| zuPC-DvX}~zbPI#rv37LAyCUJX41{D%awg3t1ZNI49i4e-qNfM=jcea%c`#&N+Es_I zWZfrJ>ZR>%C1h}MRCfFwU*(XH!9}>dFfx)4jDX@d8B+M84k5OCljG%s4UD6|Ess;Z z8}9FzjFr(E)rCIOS`+^g3uT>Zi6L~EUqV8*xE45!8e8VJNxI{&BOr%0`zOt{_7Wf~ z@Vfjd?CH-iZ=RHrmZl6wL7HwG%+d99cT8r}Jc!(T>dVY@KVnz!w$w$v@l#~v6O!Oh zwEw)wubwPW&*`f4e;U^(^nJjjbn^*i=B*j<3xUf+_vGx?J;d|hW`*CsJMntgjyafi zcXqn&ZIUu(3yiJ~rw%Rc*xe&{>*)LEWq##0pNd7!E^&3NSc$d62=puFVBQO8M23ML zq006uEIR``@+wWTLj2(^meKeGh@2hIl-U>lI9cZ|ttPLmTw&H+_JiB`_YMA09yc0d zOs7860lJny?Q^YRVcIGiffJk#X5%H>(=+^tmoHu;dBFOt5-9~^ke=*W z-M3NCot>rMekw$+PDeaH{HHymqA}Q2zfx2|P%k2580;>Ph$tvNscR?lxQdOMp%b!7 z#07hPlg0&~Q(gHQ5O0PeM?s9M0cqDYkMI;y_h5+&R=$Cr+bmRAk3;2>M3$9PmGT)y zqLSI>Sx-eGJ}=0A9c%=9(fWE~3W~+H(j2&#P;ps1T3aE>2-Kmf`be|M#ms!V9r z?5y_X=K0*l#B~IqG1Afn2du1MifwFcd}SohXn)53&Km<%VSzu6SFw6CWqge~iC1u8 zB$Xx}1xUL_t;_OGvi z4pJ!vS=nUdHNtsDk>t0}^{4tLdwRAesvMS*Nkjz~E;GA&-x8VD`{1`q4eaDcTw(`7&VMN;GOxxRzP-bHa2DZJtqzcH)Yo zONpt0FD&E{jRMHDGkEQP^jm+9k6ZX9PGQvnqrJH`7>={cJ1;IaCaT81m1i8C#^1jC z@QjfHl_lRj$#yo>_4ct$_d#QTD{E!zXxn$^a&xQ04RQ+dv}%!WWko?egs2FhZgPJg z=J~^98OqAc%v>$xv~hPF-)5KM9g)NO2@B%2oLq|C?V*9(AYo0yDRTdZ{+L`J$pdvl zjVq~*im=@I^NWIyd{wedd)~?i9USje;Z;rVAFb+s~*`N)3C%0*+n5VH*QIgigI$gktT* z`61o@d6k2%qXnc5%<49}xZOdRuj*XiNA4!5(5>ofd8Q7&g93pS&# zbyQUc(wvSiQ>?xecDgW8-lch9l>Jg-B#o}?XZ@be~MPNv8#xECoac{BhlB^ z4>wGcxt)`l8IojFFje7p+=kt^T|=o^a9vWeGiie#L3HA>mqK5j0N%Ih=Ti9J%c!43 z9f#^siOo=xx2%QL=!~H;_Vx}%IbpIGY^XEvo_jUE$Hx97uOOdEFG~2rTKUh5J9q9} z8OmQBUCy;K*RtHsR8&;b()ij=d|G_I9Eh8>UkZ{fLn*Vpm+2*&A$F+i6nJwforp7GPugMNZxxMXv^^AOymF z{1oG)tgN8mEcRMK24_Km+o!EH{3l`0Rq!wLS4DQkJ&D{li_Au}>+|!e^hJbZ^%QIv z05#9^xA=;R7B6-nU;U_ka&mZasi<(2)6>%*(9z>(nCT?|={Vbb5JLktcNQ%)(&B&YwW|)nYzxnPGQ?@soCO)qNdu!zTM@6M~ zJM*njMYQaF?%k7?D6`O<(AxOT+iciiD=#mfdr{RS{m{>5QlOxL{dNF;$q4+y44M)fQulx9%eox#&`QCVy-SuyVX|MQbQJmiG;-X?vVNo|Y+8`8_vR_9i z5QFlusWqma&*}XOcj2JDh)gyzN}~@a`BNadLj15vrCfLJ^EylwO;y`^4`! zg!ht+aMO1ZQ#9qalt|X^nFD2Z(K`X*X{or@3H4Ok6#7lRQC7q>s?l9@c01WK5)x`6BebZa za?|hZ5EJLEb&7mwOCll=ejPT`AG^oGY)pU|eC9R@nv&;5d@g@Gd4B8AyP^tID*?@O zdiweackXdw+9M-MN=vhpG?Gh;%=dxDVm?}Z=f>8;bqVX{zr*79`> zXgI_l(i1mVIWC7mM0vp87BicLl$0}6(#uQ+h_7(D(@HKT7A1wsers6&W*PQ(=Na;M z{j>Af|L(m0!NGOV_NCFI&fMUwO-f0bTU-&6x`(iRG$bCI!%T$HTR8_Dg2~!&q#vVg zFG$X4DjO6fXm&~4Wg~uyiXmb&i|)8`*PxmVI|m(P=jNjPe*AX0wJQm|<`N|gOHp3g z%Ek_8rtNE$He%x9U}g@#jGlq?%4M)k!Bb}@n1JO46;){N6)bchIt)xHJTAu&t`cx$ znWdND9JMbgF7^)&rl+NSQ)ozxfOHSB(&TF(Ge4u^r1TyT4Z`EL2F%PD8h}w<3k!YH zpSr?1Ot2q>@3Xxu}*H zY1g|PJN%~J&Vace?^lO%-&8otKrQ$utEWdUsRY~B&aNDU|E?s3icIctq2b}KTu*P` zn3dt@*BK&or&If;u1}1|7wyKzCYJ%2q^ldKUrBQE~AT;qy$;n-Ug1w4`#l>Sq zR>et4NvXmzn9JCl;{^9kBYv=}F#ZnNMUnDJ{2W8B+Vs&f4|y-Amb(;e3|~AghLXIJ zn}vbZXt*q|hfpgKnT-5}1DESTganuLJK*IBoBT?>DjW~eo_-9EKrq(nV&Ze!tCzg( z4XcYn{8-~Cmim~$%EBXFdO!~-EULC z9m=CL!+r`VnRomP{_nL&9)5nhtyyZr`AL*~F0L-e&HBCl{jnT6`aR#~SX?=hir*}S zU{V?dQ||BYXGq5qIFY2v3D3Nl1!!?Lw}Iw64(=k82%UPc=)aukRbBLad$xvKjAmI|p2vy*Z`>?wpivyB?y8p;u?@ zRH$SmFC6w(hr1hp-#|cC0^PEsm32J@ZxOxV9XMr)=fqhVtbX3G=I(6k(x@;*QVK*_ zrAr@r}TQlxb3TzNTAFR?&Pk z)%PA!FJjJ+Oip5I3hZ6zt z2J0=&yR>uz)&}WuFm)MGM?RVh&DkT>zpa$3S_zjj;`Vsy*sqbB2r)4+Q0w@M``Ovr zc6^E1o>~(1Q!yvG;JD~M`*;sQv}5We$%)&fg&0W>gc$`SBw=Aa%*@Pu@@JLJY*gN9 zNmD?+A{U}2mte=bx>CA6m3T!+*KKfSp^#=(2&&nJ9JfIUV z#9D|8pdXn^a7KU7D$tC!u3K#a?>dg1on4==7zx~Lotl9 zZ`|r60>9cUViTvm`;!@{#5WJwj~SNnW|tXcoh-=ihapLHh|oQsA*QNlqt1K?l_m^x z4gidc8^a?0Uyunb3;3Nj^XDQnZv}5&{fD=SQ!NXVJ^%jseOY#8nOFb(H7dN^Orn4O z8Wx(O{6ByG|Lxn#FW@7{-ow+ava&+F)zUJb>eq;q1aju?&cZXqpUFJ6_9)u4C)M9Q z>x}|xFi%$$o**Eas0OA?eW--lascaUn@>UyBNq;EPCF;Nk28uGN+Usn{SzAF&q*7e zTRU%r2UGmSjEt5cBWPf75FhU(J3F(5lU*XUS|q0YEdc@uNV|{50i;~aQ@eSS@MM2I zBQLMBt`(TQ_BS_>J>th4-U@!qTxRyO1er5<1YCPLoj;-6N9<-)&@W>BWO?Sj1Nsyh zGC8s~x-=*1Lza%$4zC=K^HWx21ohUxm)=6dzu|F(_&7>OIapR;y`!^U?B+%CIGwH^ zD>sjJu>5D?v~vg)KzROY^sY z3S9+~+FX_5o4boPJ0Ce%(`;^hF~DIx(RgGz6wj>qM2FD@)WwqRLxbzFm`-^-h_YHC$R;S>PTMXYT6 zhUOQ-D(Id*?RhN=ai_6Ky<>}(+C0|__`uY!} zBf!vtnSn(w(LVrQ7#E@zj~>w; zn}{Bb6e9|4PJ;k)y=z{jKQa1snh3n>PAp)ycp(VaBGAOsh6TL| zq{2G}T$*)McYN<4LJ||D7ipABEM0#fBMhf%&qF_W<4{=IFaDbgKsX8vDud3Ln+S+6 ziDxn?`>f^)J;tQxF=(EVJpiSI7jL+?cSe(#l5%M}=>sJ{|74A`@Zz84<-o^0Ki&N_ zf#L8Aee%QuwfTYr8Npe;`J9ze14vQ}&j!u5WE0Z1(6Kw;2E+Qnv>R+U0OC&9hZ+D4 zM(f=UevnmnG=n9l;dTK^5>M$Ha_^ZLSaCS*a}zhQ>UV-(^<}ffi+l-z2@E>ki^mTM zqGDskJ8iA(W7yrGR{sFB-?y2WPdS~AM4(9J=i^7m=hy>jk@3y$(1g14ot&No|JkM6 ztYm=>tim@CaNzUcTG<0vH~7L;U=h4}pwc%TMeqNGZgVuUMea^}(}Oi{43GC9xW`Vv zJKkoyPZ05$*J1K$flNtSm=Jkvm}E(+a4?wS*wU@28yQi>q$DNHaP-+gG8YhAAAa`@ zNRha2S7^}fm6MePiN{O2BDW)bB_t&%0urZJ=W^20$QRKWd6@5h=)XN4*+F9I_(msj z&4lE`F2b%jGb#7Pd9XF)K7!qCkC0Sn4hDPPUS7IO^37S$KX`3p>PuYq{`i4F06+Qw z|M#N@Prg94yzbPI*$a<>iGfwiUB9B!qr*HC;!4Q_F+{^- z@1bk<(Vm`6WRDrI>x0^#fX~UfAMr3Tg_pUx{QZLdENGo`Z-1|)V`s;jT3DtfZl{P* z|CXT3g}SRD+$Rrg!rQX5L6Ya~^$GD3XjZJbV=r^k#;fg2nxvV)ZpttT(xYCA7`7x# zyyx3=_S#%+9|3QrgyUUldGlKcy;`+r$j&Z@8xxzw7l0-rn}OM3eXou%zw}4*sqbdf z9`Ch3$>Gt<%E6r&9TxVJmx+_oxqne|i9U|EV$%a`bS_Ddf$nBYBE$E>eda8&*Z{8o z-bKv{#~HXu;Z;`o?wB>bi%Z>BRBXd)KI#a;U88rCGWaZ}YV8=0$nq?sp9(*3BAUb@ z#6UnMC*Xt6pC09yzlGQFRegQvQkNti@2ifthZ}=cP{n8XIR%`&>Y)4`YQe zXUO$+>W^ad5o;okz0E~nahULT=Q*N`TojqB;KS=@}U8v@s?>*X;uJiVA#Dx~>n5B!A+I zM8HT7ZaM!aL=jKv>8IS^UIKk27@s2wi1C*V@)tQwBm4CY-VlurGHPC+o6Kl7PIp+w zBH@g`#>T>uIJNpB!0?kOMszc7SZzNu`DZ2g7!sySlg4z8w7!dojt2TYG?Ji;K8WW8 zVz-8E6-Z~~C}vBY*(Zte|_*2&2(DZ209UsP6FNGEc@2vJ2|o_XJUv!UBdO;uT> zWC~uP>mE^2Loj53L^{xkL1rM%<~Q(Hglli2()kwRMaky$=F)JwX1UI`kTc8&j}W?r@ohQ%bwRs6WL! zI5WGbO|di`a~rcSGDpenGf$vI6*>Wzc7?aEua&51>qJGx5AOFc;e=N}Pj3NfmF+dg zt2wqobc8AA?;u>xuD-tHT0jEfA>y)`jTgFIFD**t@WhGQrt8JPP%AS}GRKFe z8H^MEu=KUJcSD5tPe42DHzvvTTToF@KB3|}t?U^gDhO=&w}RH|8*a_pceeJ1+WTq~ zMyhkz&OfiLQB+WfpIu$;$tXzBNU1)2x0Kv{^X9GS=;%uh=O~~W0j`uOsY|GreE#3y zDES}DylwJsnr8MK5Yu;y(;WUycKW4dn(O5oI^!FiniU8H=w{}7GNef-gl>NP2-iQa z)7n!6bmMEyiTa>=m|s}n;NS>+%sSrEQjwZ^1TWj^!R9ksT1n^lGPsgDaoMcw^x0cI zP|P#+J7ZA88gIAjHm!3M4Ey;}>z+C6+Fv_Acjm^li(q*>6652ti}w;P_Z3YQEvUae$`#(Wm9!nSWaw zPk~BRD9x9HIeKm6*t;K$nEX|29_s5YSrs+Uy~=1g>(!xLT)^I?KRNoglc9M*CO(`y z@pRD$Yg?Ec(Xan7wtRz{Hg8t*x_fyWg~uT@42 zF48V}+=kMe>5KOMe8VJ3Hmy(AyLmJ1Oq~8rP*Rk>er?JkQy0dAa|VA0ePMZxE^!GL zKH6kKI(JnP;WsAJq}^{wo}m-05LehfWKraCz{o%cc=&xx8(K)x@^?Z zvs$@DsBe~=oKc>tg1kHcEG>KMoZrHbWlJ{CGx`Wr$4ZKmYTshI%-$%k(xI7`fBLSA z*=?-?j+m0@#{e{%Txy7?mz$2MD%#979PcjnKf&VITkZ$`1LHXi-B~nQIXqpf{oMr+ zmW<3^5=WBTxRA)ao}nK1hHRy8qJ2_BQ#JF1ot&6Wkay z9;yw)%?`A{5u*s>R*6s{I{hfP=wsocug*;B?dkc`A3K?A4wPwN-4E3DXGms5(n8gp zF}cj`dcteWbIR19fE*EV#PE|R#>%;Cl_@v#c}B9V>j8KW-i!v;#G39_Yj{0nJbt_` zE}e|0=ZCBVrT10W%tl9g6sNZx=Lg9U0XW>MTaTb?5(?NN>PIKw2{GIRZrc-P!ykhL z!+9#Fvnp-=22^N2=_~+FZfV?IdN9@K^IYL;6d#H`u0%e`HwW$Gs>C*N1qE!Q(OZZM zw+p`E<^C!*4m)Uh9p*OfzG-$S3hq(bS|4*D!aU3K(hDb5ZmJRxmRC{1*;&}o`dP8f z=We;*2H+F^fAV=YrGU@#W*+!FN3n)B*pX}S+(T*$v$M0o!~?iNZ`s%wx7SA8mo%eb zWh2{_ZpZW5+wK!RgHb5l;@C?WkMXVw@WHT{L}qJ-54xI|G4Z!D=LAtZ3G2@3*yTh; zvx?ssFixb3VkUi%lyMl`tlK+49y0jUfbtY$TgNN>?;+J)nEX?{Jb$9K@so##_cr=F zE`m#0fC7M*V!t}7P$-&TtzKhqw{cc^MF#tBGlsT8b*-*sxIj)$4(UhP2Q$exoWKX> zr%2#-rjS8oGBvdlK&=ZO2EM%P9bX^!$q4ktKJI-nHgYGvAS-L6z4lQ|IyYm4e>A;j znBpSvmL7y&yH$lofjOWf|0RL@ql3uc1*5t?Fo3!@|Gk<(1QYr$qpGGdjAhDQBLr)S z@OYyB?97z?@=UGVJnYU2MdUO!^@zBnB>9_TXWLDgexKVDRd#ib7e9aPKKg`;E7lpI z7vY-%&XRmfpq7%0uhgt{q3V)mU}x6PkjW-z;-y;977cfm)U_x0y77qRjE2Swom&_+ zng30lVPHeU>`aDKXPNQiiLb5;M^3ycr=N0XTbrLm>cH#D?9yRQ^oy$CP^+M~4>up+%WRCTcr{-ZC4dWY z)k9j<)bw=FxC69oZl;Hd;3A;k8RtOs^z?jvQ&Zt5c94m|+Sy@@rzg(O{{c@%vW{r| zsZJI+?xd=S=X+HupYiM1KS@(%+zpqdi((!b7SLaMuZd4&Cpol6x(Q^1GjcR*NFE*2-$w~PHK8M z0e4$V%l#MWl$Wa)v}0v{7oB|XLuGN^7B>U8!Hl!)=Eg_^&*L>5*LPy_va&Q&AqWJE zPhLW8NE>mQOv;MI&06=%gmWpqC{o>ro4+%&~>>9){XH!$BaMcXus@+4t#+eUV zU+&LHeYXb`3qwN72yywl3hL_dg}AtP-HE6@`ScVpp15)x&n{_;qFNG}B zj|LRHw)>tLN=+4ygN*a~iLN`Tm%tCdyUzd=6pyCa-`=hPkJ5!_N-YM#(9bvgZ20-J z0Y*Wp7sHJ8lPlKIv9X*MGiR>vop09F3JcAWt{EFH3S=$c%oljP`K_Yj_oee?h4Ufe z$3SCae}7I*U?5BsfP;^Sh=>4SBFJ^CQ8-XcJu%U}I9`6+1c&&)rSG-@hF_pu7RO2=*4iA5hb)UX5N<)6x7j!)X4|J7@B){R$L0RF zC)oNsk?Q#(EW#~}lUdtG_v$XDABn?_2AkKv%7LDNifTd7uQbwb@7NI0)8D_^TXV}y zFH0y*pf%W-gLYXQ_v*vGmX`h1@eAeeUE|u?@s2L)DZHrv^CJuOp*$ro>NXd-u_l2I zj{Relz^bTwxCBs|{(74a)&h*9h5%cdjeb)&)l{u3GiLmOE+Ihe2(UQWOFkEXkmZ((S7)ZY=K z3pUvAt3nvd|BD2{_Z&zNqxYxtR7?AMdJrC#0Y&W(T(f~h)Z;geKVW#!xMUNrFs zN{58E34ID-ikUrRFxcn!rJKmeDp}A!3ScdS~Y#6|rZ|oo8gCq7&VB+ll09e?1=b8%bi|3@oCGNsIUYC4ImWI)c3t zOJ&U7e9>VUiIgUMz{V$XBR3PV#oZzDE|jFABsrPSQr}9nwP*owQ_V6Ec6BH6-phZ< zt)H2lOrItc^x%6^#8&xP+;g{U#|iTGQU}#HcaPCu!`;~4?=P`AR#%X&2`yXB@`Gno z`p&6c5Iv!2lob+N=+kmr%2T-bKe!(vTX-jXGwxW8L`E{kl9VKh!0U*C?;FEoJ73t- ziTU6GkZ5ir(!o%I1zsZ_Rt`QFSStnWNEwYm-`#o9ncxtB0-C3zp-r5)g^ujen3@VE zH`hI~d_{pB;PvUfm3)z?nQU}=JSMb*$PqKx_Zu~#i&S++4>yy~O*AG!*F_j>Jce?aA zZ^BYg@YhP+M;E;;vsl*os#MY7up7KWT_7cC$PxD6{2j`Z`IN=)AeQYx^~q8Q2tZ~9 z{aYQ=;ggJESH!w!T@fP0$Nx=;_)Ya+gou8P466qO$j$@9!{sFk2P_Y!4L#;Y*V~tM zq{2>j(ac{x4-#W-mCoFrYYP38k~5t9&=kvR^Wq#>CrDQhpsse1)S`_cXNc?>=P8J5 zN@D(#8M5T?nD7aJT@vE@zB|z<`6jcJr(d4e@(qq9e^OiPMp42E3F$+PT(j_cEm_FK zjd(ds_Xa8&AiDGe-BOfi5~%wybc>8~bJ?e+{UZ?4GlcQqCOB|&3-y(yJqZOG)9;Rs zT$lv_G;sX{J!IpRo1~_+MZ@TRLP`x_Wzf3(wkxvv)9+1lrBx_$Y(2ZVi}ZH8kKAEn z5@&hF$b2#mPxU{T3wiYTUCP>p!hjQGUC6ake0wotLpQs8l9k-@05?r0d#l-%i7K4M4?t1@ze4bgptuV1p?>@pn zxLxiZRyaEV%@U^N7s{$9(=#99tL($j`G8J{&$O7+hRqaU(+I|>t!#JC&(BQEHfT;A zE%YW9}}zpR6r4J7wL$zD)^+{_n5z{mJzVP#Q%OD}|FHVy>db~(V!teN z<{_oQr*ISzk>$U;E0f~t*uJ7L)>sdEiDHV2i-(7YUmoqEU!+AvMS*=TnQvI){|ouS z9Bip$r+Uj$z>*GdOhA4-1@E=e>8i^hc?98e(t$fitEM>O?LnMM2A_27m(L-BbIn0= z*U|*VA1VOD$KUV8BW%I3p{_3T_NGq2<+{*-KhU? zt2HGkca0{0!|mG8Ez|Mp?V)wG`4ssx@Hf3SWcJln%R{A)1dh7W{OQG&GP~UQbB|q6w3VXrz0`6sM3EwGN!5)yu#-%N25Lk-sWvA0lMPKbiZ{;v z)OY;kr?|vqSghySLnHY%ExzF;PSv%w7ocw8m-ezs-b)rPT+ zn8dX_wP)z_U^1fL^%=)@t)gdpAcmawK54v9c*rL-oF}eyZOzV%>$wLTgPfMb8C+roE&uaVF46{;K0R!(A8VV;q ztNEI8pJo)$p)VIQ5&^QM=soHRKo6`pA|s=vB=6KDI$!-F0Q&(Y|Mh+)?1y%y1})?p zR8y~8FNAL2k)@^%%jG3^_6-he6)30M6Uvtr9L=}}DM!yTyRtliO$|J}oNQ`4JKN5V zjz&`+HATg!`0!m~-o)m)<&_myHnxJ&vxRL(5NgypA1dzFb>*9J4<3_h7S|Q z#^t$s8l%B4QjHQK{~N0#^*oT8!A1a#7fyrl5nq86BS*YvP@de%GYR4$jf@OXDQ9J6 zS%n!~{;%W-hH~wPksV=)hENp4EDxZfq2692?NKijeR5CrB5J)~*^%I>deh^_o2oZ) zZxCNWFn>k&NL7Y&CN~D>(kwjnH{O@jjL`cTak}AkFO9FTFAV{rUzHjIyU9XgWG|Rx zJ3abplc4YqNv=F33Y7rkVyUL8rgTkPsMvI$zxK_{$x-_p`=PS(DE288mCEN>HIX7; zb@<)lvxth4!_3^=@$uYuFz4*M7e@3Cr(!P~yEs(=Ze1qgtel+i8BAUN4m=F!jPK&) zAG|*n1v~u1#t`4A&m_0y85N}%%WB4c zOvUO=v1~-vNJTj^PaiUB7pMXa*ymw?CyfD-JAYr2h=ad3=KVARmhj}KKHPRAnit1ht*t5 zxa$@2xpq5U6FwezyU8dj2G!UsfpkY#-}?Z69+%qdKEk$a6>qP58wlL65vex|TSUOI zKBq)!goA@Pi5Q+R;<-(e1f~w=JMVk0AFwFhTe(M`b!EN0EegWj`#WYW7?uOuNxW}y_1gHo7*G&)*jaZ)7%gl&F#4M#}v*t zJ|khsOYBs#zoZ74 zS8>otFj~}eA+`>8GZf?Q2xt;SRpVy`4}S4%x2;?>sVOL|NiQC<&|b||BIfIHEeP9LtUu^dFR<)oAs0! z$z3GH2*5z^f2RwSG*HRI5DZFX8LMxQx#aup8vq;0>*P?;o}YrVVuqQPIbC3(3>KM? zt`MtC@YVl}Omn{^Sr%Z<2Tz>-v?PJ$@@poLD_$J57l5;Rj!_OUy03eqefsDPc_Ok* zaBNWoB?6xp&MtvYkQQ0lIb5x{Kiq6L+>IgDfl?{~;oMFR_AV+pJDG&} zs@91tx4XLw4j^D$%YT{$2n8sCTK6U@syt7>J6yW`^93EF zp+Cg>EzGhSE^WLS$fHKgFD((WJN_7)Sce8?paXaha3?_G7GP!teSOC4nfG*s!Qba? zS@b2^N^(ngAFSKL!7te+`Q7{%cOG3CzmF;rG!*LO#;i zgYR>*vmZQ3kWMsKdF7JGs}!0DQs;2?RV76QTD5OnWXxo|yi)@MwhiA=OH}WE5cy{p zRISY6M*eMgL2GHH)2yPZDj%W572sZBbC?e!+uXIaz*tsu<+}G}t^duz=F?|N!{>3x zSN>^8FZr}d){PuMETn+{9g6(>hs0-~0GNcE3h@$n!9zn)?lR*w&OCB1&?zCkg*dWqdR;ZG%A)qaI&*^}RgR@=mgHiUMQz7mI+6WI2jO$-=_#2N;fo>7}(WJg` z-9hk4Cw}3zsD1w)sdNW?1eWNPU#4HG?nFZyE+3l=6dN>qo7lD0@R*o)6l)S-8<|-q z%I&mAgn78}s}h}{qwWxu%RoCO= z33ep-I)EsoN9rfMoq$&$rWck6zrMQObVA!-SYf5t;|`Julb}tf{DTPAHd_LaCYu@;!##pDL8kg zjhQSmdp0=UlKzHs=k-}%WINDg(ue+~LVs&ybO!;nh(p5hrOW!iI`oql>|5RF+^&8u z{FX4>JF-wfdMPSubW*aMr&fB+qj0MbC{K)2eLsKJ*CQdcHydlE+$A~hDCH}bcUjxp zY8O9x1s0GGi(zxutgQoK8i|O9=i-#I%kOQ*_n&6{?()^1e{D@QsrJKy*Zi6 z6Oxc1wa*~~dSU$ey9jt@^s<*#px@ll>q{X+D3{p+84L$8?&CXi$&x+U2l{Sfir{8W z7=vFSDmo!mcuI^{zYBLi2VABY0|U_m&K2P4Su}pH4u*mQr#M!I#>P%T zdu07!2C_vj27|71>2dW55M{SYG%+N9z8cjvS^w9l?%&Vt>fK!)k!!J(MtnU_J6E`x z08PV`27bIdSNiq`Yp-76tf@{^JmcX>_%c$ZL=P;#&mx^<6Mw*G5mLEcnErW=@bL+N z8;@zf$`aRKoeOsNebVQxX7Hy0c&iKhOa-uuwQ&dkJjc%Qj$5 zJfosQ@(3m6%4B;GaQL%8ZFqxk$^T_w0rtP8;gLBY4IhwTWk@yz(#Z>l3-zn6eFDDs zzEm(FW$0h_r~J}DGY_?A%23~yhp?nUb=JoIWNTT9k0emiTLkWE&XKP~ikO<_MrS;V z;c?%-hJa~fZhrn`chcqo{+Nx84dMBzkF9~U^Z5zr(-G4kQ%+>l^t>7xF=IY}q0!c} z3z?+7{e5qA{D)2_1M9!XpmvzYM?vL;5`pH*Eqs?{O4Xgy(a(yAWQ)S7=T6) zaF>@AAtMNaAHCbaLjVJ8uV_;+uY*DeON@_?ug7;qdiZd2xBy0sd`ffw`D8-DqXZ+S zr~i@N<;0cb8F>mM&z;PW_+n`Z;<8HXbz?a5K@@9aZLO`Xjri(bcX9PS-=fLu~&=k>;Z)RO>o1o98m%3Cl%jxkl+YiGi_8sSX$f8 zZW{mJH#f|F7ZRI=;*tKX2t|SllV$Hg z(ir3ROv5dV9GT$b<7O6Q4}yvR(cV`8Ro%7wqNsoUG?r!ed+xI)~_su!?-nsYOIWuSGzT?b(|NPfl|CP^rp5IfsToxek z-~?p#9go*4XGfH?AY-cb4X}mdm3F#5gq8j-P}8h~gyg@2>W&N#lVSr90ML`@Rm^{N z*|qTa%VpR0)LDm#74-0b<@M`vWD{T*>@Re0^lO~Y0j{I7bLeyCWv8wK5V&}YtK#N( z!@A6V5swOC-D;yi%rzwLecth6`bh-HJ#!AmtKC${m$<;bk!f;%s(nBB2wkJ zMhYTd$Bv-~yG?daKSu&eMURUGHl)?5yvm#$svCH;#XE5AjF%tAEl;p5f%Erx7&y){ z3%(!#YEfo?TWc#0+J;oY_wVydOLyVg&rdGMw;DS-{C}qhUTLYkW@UvCzr{nsiuejQ zTY_sJ41#aDTj;f97e9_eC`oYMh?H6EKH_21y}YyEny{I;33$x|zxVZjoh=3x*_*^$swbBF)eB{^7d_tU_-!!WF@@>>pmBE^wP-sctoX z^YROOw!G(9hV#8oSbBdid6hf7j5zHZ53k4C;7g?4XKOiyg}rB5Dg^U9-ZwMhWZZYT zVvvLIOlRty{Xgs9JWC6?2V#j7pYM$1hk23!Ou9AbX#`1Bk!C{$%?Gv{Io5D2Vq)UQ z_-Dsk>ZbRI;UcNO%UE1J1PmG^d5Yy(5Joz^>YD)p3W(-;hFiu?^-b3R_XP~9MAhV~ z;aIa#{_HZ4i2!M!96?5!y&*^a$56GR{ube#0G@VH41_;|y864{WY zT$7wmnL@)0HESxYrp|%J8r<0tuC-U0r*UtXY3Qy_*A>DYo+!l5@5O-;Ai!1Ju|NW_|+{7PN8vDn0(x+_7l?4fvJ;_{IWo0a{ z*x5nQmWn2L(18Mt3?Tj39o)LgrJ)8^Ie^=Eq5)Dnhbt&Z1WS&WTdK=5wuKR8-4P3l zrUlM~Z*g(#>8S%Nzzs7M9g+Y7diYrJ3^scyD5zDM8Je5ZWYqomR#33T)U>c0-i)MU zVlwt^`Q7WF#~_5l2#vC2EK4!y0SCuf81K!jHUMs6Ir_pjLkzh8p|=}JP^@6rkg;@U z3kV1Rq}4K3KLjxfe30NCU=BQ523Eei)>3Y)xxJB70_-cY!`AeZAF|@91GpJ3C+mEH zQ&ab7_8cTno9eT;<313v_%|fQrUH3F%b#fM2}cZ)F81PH6%y{~-1AP3?v zlO_hz*H}n!ysogID(;hNFdjaBxNhjivj9>-;1K`1m|h4ZjVP;<1O{_YU!> zwv^crt0EGvD|nYG5U~AM6gMjcagQBnfI@8!$8 zNC3nZt??v7AR^A_G}I!!|A7k;{4LdO;7`LLfA*Rn3J5_bEuy?qH;)^{{Sl1=%w%!% zB4_3i{_FI8`3w-jB>L%zHdGkVE&Inx3SSc6z`cE{tN%hxyq})lP584lv$MA-HwT0 zqoU8Wrhm%50Cs4t;2&{!=?u>8#a)Ki)_OYnhGrAFahhDrQ#&k+I8D|M{^5>Q%kV0o z`sOVw$(ub>-`ZxmlXSy+>26n#?Y0J_{$hLP3n{v1HEM*bW}RFb_Jn`cS}z}K84jvU znGE$H+3|EG!%8tC=lL~;Na5+SN2wVigxREOFhHA9=%3K5{Q{i zLV;;~LQSQq)MO^*TD4_7Ye+;{eQ!<#XK})KC{eK2tx~)mBS;yNUc@^S!3mS}aF)Sf zBbLFdg~yLd#=L2N?Kl}t7c~<%#5oBaL>(?9uB6Qvg=_kIn3`C59Bnnb@Ya>tFTADj zdiWTTMVUcmg45&RIzYg*UBT8BQ8YE}t1vx%PlQmo9F8oNdLFnewdata)`r~OP z7v;)L{udZY)sTwx`Gh_?&GHw@5W09F84x~w_hV75y$Fm0mq2%C-Tf>CS-ERNd-6OM z#Fh$51sT(3OLI5W-H(?)4F@x_j z?pXwLD_dM*!{{iy9X1PoH+j@Zq4zCG5aWoP5*F8TyZEt5z1snP8C4BmAS&&!JFZhx z2{yq0X4|N62M6?n_~W+ROXm8+ZP3V%3N1dw$M23D(eXODHEdEd-ge(Vzg|Co3RCP5 z{_~F4k2&CsjmD{NZHJw-6?nOYBi z2+km;0_f`D`mj%eU#|wd&cHY3Q7O$TD(HOMvUg|^u4?p9>kZWrIJpHeD33Q6uDpUm7s&( zHOiF3h;YTs4VCv$_ig39ydH>M7!{OPkGMC`tSlPdnY7Z*GqI@T5^mtUs+LikFmiYw z*KB}0{}9oWsaN%KWKeJ6gJ$c3pup9JJomH8luZuEnY;4!h6C%}JBPwi4OcKfWAWTt z*%S;M?HpAeHgyQjbmK4s`1n+nN@mvFli+(dYsi1vt8z`#f}nlr@i<39beNcvXe#l* zKbO-yD-B-n9b#Vm65jkO5%;7x6_wUu;s*PZM`5J0gPyM>R%9p=O`nu37$a+9aPZ@n zk$zZ~!z*iv;Go#pa7>)XzpWgUrb~XajtN1B0WLK2HHKRWf|C}Cn5{>Z1V?TzS5EnA zUrL=u4ikIcd(HKFzfku}o0U6C@OY~^It*!Gqfg=5gS|OJo*5e*btwIP-j0m>{Kwt6 zwlh)bKg%tUR;~#_vvEDiqZy@(!@^`dJzMqvp@?p?EZKdzX8q_n18u`a2!os3(L#_7 zN*&XS7dozLmktdzHp`J^#8)fzvU$!=Kx;Z7@RP@*VtK+%H{j=HIy8ACzLOqFVJZh_@Z>1Dym7Yq8fz;m{c2(!++ZwxLwVSvNWv+I4#6<3c z*m%Iu0;%7x0>f_;tCp?KN6GlE$w2EbkFsG zxr48;5<_B{vu@$w_QuA9{plbVF%F9=*Svm@I$JNSic}hnkuX}C`@{bD$o{<|H(|0S zY3JKy)L?bbb$0fHBu!D`W8?6!P36W%jtb1g9`i>kv0OneOpkQ8PZvK~yFw)EIipK9 zb~8L~rsgQj{V=~*k6iCGaMkXd;w)vn5SaB{k{3SU!o&Hu0ia~%2}97PpAxvP7`{vn~rHYRr(rkPr%xy{9!n1kI8yqRUjlQ2fDdD-8rc3)qoeSQ;UgN%&qjp+oV z0u~#qdlAWh?WW}CVPHbGoE0OQ*D#pxp_6=_Cv`tmCiJ-YnN!k5u`~jK9vq!WPhSf0LFzFNUEOEDeG$+_t=;f{Y6-?Qi#tOOHsmA51b+L??*Q_dj87GWSig| z@i_{;<&?Q=b0jQGY;*hSh`Rer1BoOai@{>v9~uZhr2YA&WSv`@Qy&NB#JEV>TpPMy zx3yuojyV=ZQSzh4OxP}N9+4Rrp_7y_V{1>=SnPWsV(ElQo;#4F~D_3}j2p zx|#VUYs6Yp3pOVVxRq}85Awk7b{N>{h3?J1j4v=@ea>7;CD2e z=*RF9aYqBnAnV+%0DLrXFsd)&Yd0g`1X1QaLZ?^E+gd#Bs2+M9=@EqDjV~yiWjnil zJ9%2`NFYJ&JWVQ=>9pwPuo%kA!qaxyP0VxRW6w-{rap+oeeA8v=z4R(ySp#U%6imy z`&q)*%UK%m%~UfJD_(UuDrlgvyw+4Lqemj=g}IQ`cvTsD%*4>Z+9ozBCG#GTn(L2R zU#{zjWHlc}+;FB^J$?4fK;>;IZwfdb7G)5H=@RNk0-qmxRNKQMC*&Kbt;=0M%zhp` zK0LmA=T1+xTfCT=jh(xdnS4;)^dP;rKk6elo24>9b2#5{IRB1hk+#+b)F}Jrh0t*B z-D+5z==52B&ACuUcw*#ilC(mx5d3Z=e%;o>b4j{&wpVoAa9NiXnU96}BDy#Ehu!fV zlK0vvsHdA&>fGGPDGg59nRC4s#aHb`g{p~M`f|CLq@-^TQ9O!|^Q8N%%OH5Sk%#2< z^hhPwqb43D5}#&NY|->~OUVlKmL(Q5cZ3+ zPD+EtgJLwBJ}8WoUpD8tY)`A2%ZL?^b85RU^v^LHdeL9BYi|>=&oi-7IWI4ktwpru zQe{+D?j%j!b=e*t-w?j3-f8SKcSEk=#CU`r5Wtc{)w-~D)ja)OyUxLNY$l$Ta|sIc1<;4B1_#LgzYqp<@Ye?Et~ zg@$WBM+c@!xjncGbJx|LpHH70Kdq~*d`0$>)lx#j)y~n{TD!EMG<{P^ctrpsnnO#) zz4pgjPEw2YmdLSJ5vZ7SmYTSQBRq#8@fq~pdrp6@@7U<1nw2qbp+|NtgL;)^V}2*o zxi!(>(&M2p&S&jGU4AFu)R^hG6d`C@O$2a$aLTb!mV6icdI$w^{A(T?Vk9IlFnGwc zPi3y83&mZ{t2iT=#QvDOz{Q6C*5K)gb;=*+M%+j`@?V&n`R`4*%VB`6B3Fs!<)Nq6 z01Sh`A4X~f-4?K5XqQ_je{oiKhuRI(TRIDGYej`)RqE6b)5$zWjDdT+N-usBYgWk5!9UET`9_=5zc7qNPbG*?#%e`N@)}<`TTGLCOl*n^B05`K#0~ARxE}@h zP9NBl^KxU{&VS=oTLpbQ4yrRirR>vXTk)&)SLb|x2xo;E-}ZTiydj+Pbzd7+M8Dkl z$Ge)7`QPm}C12rOaYRJaA$T%SCc%d@SdF-szr;jG%Ku>t5TjhG(#jSq`OV{CX{03Y zOBj5rXVC}STj~#w-hj`dI<9S)gGazV^B_hX7R&tR0IEI5uy=kuRg|C1{6-#v6OXuA zcZu?zF1la%;DCCJZs6Cw-qZb$Y#WK{@F=3`mk zPl!dyimb9(Z~BEyS(HpmAEOysq#DcXe$=Ev;rx&g(eomzh@bhENk9ewG3=Io2Gw9V zYZHfw!{8v%#c_u(?WbVF%OR1{Q@b8?;U_5fnv`+{77c6A=GD2TC9CQRM22Ir88*cN zc=9Y_=zEWDN%!8LLox}@sX7mv%h@DIN1l~-VI*m*=+<2zq@GqjWmZ~g&%UWSC8aDa z9VQIPn11@ifYJ9(oa8sz$GRT}meZ>`ibV(T_4V2_TK)Wdx(a(y5Xt`y9GYAah!3kb?ev`2t9U*kI{)(vpf>cELPAnZ?XCZ?(c=wMWnsuK}DA4#` zvXH+wWlx%#zOe%@kOaO>7v{0;PLq>llJ#tzXm6pmk!{srMR~ase4`^FJ<6QUPWUxs9G_dmg!V_jr@9U9Mu}t${GOisz@9OTc$54L!&u$L4>UPdCm|(+hJg_n z5Gpqw5^Y5F240|w^5`iy1t&E-yNK9Bo6jJRI6bS7sS2&qJw^EIkPqyMPlFUq^Vqhg zDe1Dk{Srv6r5*C}tRofgf!Cg&h7%5eYG(ICME>jayEr88<@jp{O-##G)zw}qfrE|o z4prCE)Y5cITc~KdP@IH0J61v$ZSSKPv?jB6$wrhG-pZ~meN0|Wrj#jVOM9FIR;aXW z=L2ef|HX9g{(el7_tMhRf{F!`dY~kdhmx$~#Ly6#H|oQOqEcdC)PomOvGDL>IU&J= zR@OV&+4k3hNE6l!YoacgVQL{5l=PU>d*i<1Q(%;nwPVyaFtC~RC#(M{Y6h@MEE z4LP~>BNmpjX6;VY-Ee~>1mZp z_0g-v#A%yKrp^F2x4P)%rzG!7%UhQq#)j#>-rgOBaM^rU6%6Zpu2Rxo6eckj1&6Ga zXs%}{#>1_0#hLze-|E!V)WfnT0m41-_IY~72h}v?v+|R1`N=6-%qcTa3*hrvTE3*D z>2=P?O2KI;A*T{6t0xLddop}vPqaS@S$Cr1_Xk5Fh}+8;)R1}1KgPvnZaw1QFhfO4 zLPAePl3WE}1lQ*nex(D07R4$f#{2>Ta_yl5P3vcTWq1nSn00k^4?~`cm)g%p$c8^I zE*=<=mF45Rr|JXPRHPcf#T1v9mzRx=TQLaBlDD+Kq+cxar7(iTBQq4)p z$i8CDFC}x|=)_}C2ln>Tn~R(06-Yuz;FY#p--GmYZD6+<;O=8`qEg?lgk}}g)fLn& zY_p?+60<*hmUpu|{IfecW_1b@W%hMy(5R`y+J}}tus@T^Duqrg*+8eBhhbQ7Zw&aR*o53vxCo%FvQ-yONa3e3JPIJ+lL4$Qc_Y*PELAO z3K9}v>f4D&Kn;b@iOAFbfw`}m&DpE#>ouyfvZ{)T^70b~#U1o0VK10SNa*N5>Ir=f zrRYwBubOZgOTJs4I;dNzU2SuurfO$i;KWa;h6L&VmxYQW?H8X6Pc1Fb^r`(f=1Hr0tzS#_nQ zVAqz9{I)1(ee-67jb-xST$F=@Ia9j3v-j903AFsB3HP%YpYB}s=O*ZiLq=9sNOxBk z$UG%3`SQ!EPy0enPC-F#VLcd&rY7_1NQ<{ON2&3z&o34)v`<-8?)RIp6uMpRtqtujDuKE8 ze4L(@TH?}|-1J@VXs+k-5~n++7_*fZ#>EBV{HiIqX7K{|tp?pI=YW@h66oAnBgYS{qg0zYz0S}i zt`R@!d~G(DWr={JcM10c19`?vmEEeGhA5)*!8EVNXL`ZcSJ>r*YOBNFKZ}(&E-sv; zlH5eeP;rFV!8O|o^|+;`{8<@jHdTB4AvLmm7ua2NeQ$}dvB^L=4RiC2iY?;d86Hi) zd_*AvP{`O7>=o_?LvapW5ADvyseT4G zGjEDK5fiBqaJgjW)iwoxT)=>8BCbq4(tJ(C zcM*m6>^%geKu5yElDrc&SW*)7GA8mkekAX~J+DdUS@8$=n!y9WWxk4M7#iBZL6}Av z6VmKw9LG6>v2iK9ATVilYvYMTA|qcCQYM5IG-VQZnJOovGS=V!!W$Lr@nY9F@YMt* zT5W)d(0T(4l2@;2BN4>sIlsFM$H(=uL6sWEVve^OR!qB!;O5T=!lkh+?*<=xS4s{) z-uwx_D7^iH{Z(pBzD?@QPCR8$%YJstv=oM?01EZ9ck(T1@ZDU^gHyPublW2p!d$)p zubp6U4?ZZ0AVE}J7J`-DIJ#o2(|~GQBf=0NlJ^m6MrY93K=x82B>ErA3jPIa`~W$x$m-HOu4B z-tE~J0v3@}wic7I-mhTxP7?w1`_0A|${Asj!GnS_agh-R1Icy#{#{+Ug@yDHo#cpp z_<({kdon*;h5#IZ8m`6S>A~X*?iHygGj=DA_v#`!Shvy5jp&j&w8V7rNNpW~g_fGV zeF)~fkzngEmArv6NLuXqb1`2wCx4w-3O^?5Wrn9N(1Q(Dx*u+b+m7x?0T!oA{kO-( zRy$B8>BKZIU5zu^nYGI!zRn^jB?DZO6bbOx*5vNvC2yewYF{*GJ(n&zm+-@i^lzMmBwNMmQ5HD`h4amjU)HMGuY$~LNyjuY z36FZi&ckH#i z_xNBg%lyK8?af-V?V?89xK^cs7(hMUmFW|>i)%A)+D_C2n**z1$lB%h>=0rqjt}Q% z=ISx$1|lE&6LavjRk&!(H@V$kFu3l7SUH4Y>gfrAT$w49dF%N%r5l^o;)-TG0b#7X z4OYJwf+%LLrt5h=)}FR)lf6{7X3dr+c<>25)7VmrgN$oony~; zfiOv*a9Jici>|Lt#9c-YHjF%$Lkg2q>Oam}h%TivtnM8?XQ1Ylbv+O#T;aZOyiQfF z++uJxKZ)b>VgZ%E(~Jlz=ccH0ZTLL2CEnfI@c(q-tZr9sy6ENc|-)aUK5nwt4-OTKzzj#3ju)&*ajpKH17sF>;5 zX&sNbxG$-PRDf>1!xv%l@v(C_c_2aCFk@@gnUAKAePLY>N?#uxUHK^Ij-WsAZA6VI z{xzN9{iVLX9&COhqQ@0xKgo~cc{dqc%|~He)@TocSqLqOG|jA>thIF&-j4y;=g}$J zs?Wc4A9`+meq=MO@3NA_gjq^;ck*W8|Q;={x&%)Kk!9$Gc&OP^| zwYzz^%LD?Sp_<&pDYg6O#}Q&e&bIAqKFn>cLTd{SCrD-|@?7v+yG)=-&pE5~rhdkR z1%Bkd**30)ba7v$nU!)CMvl^K`F~hQu~f}Ze?s!!M5LcBMIFn|<)jonqrB^Vk<0n~ zIZS)H_8l6UZ)0P%Q{0XD(A%StJe(_ZS!b7viY*4Yl$(j7BGpLVX-vFV-u{d}?oJ1; z_D@>p?1&j^zLm{Psi%e}x`O@^#z-^!-l4&<(%_N2dJzL%*@bb@Kn}&X+|B&pBohK? z%tz>jL(+XT{9eh1lWG)lztqOL_s}2xU$kov^9L~=qG7ytT@Ld0^U0(TR)(tz)W>VS zF6?kqZFk}fp>Q+X>jjQijB`c_nHuT?t*7ZqsCY7D6!RBYT3}BgKh9>Y>Vx^K;P`X* z6&WK_c&5F0CWdqOcx%MWxSJBk=DvjJ5_293gVn&l96Cp_geAG3?%i>a(xn;Dt2%LZ*jY=k{Xoc zWz8*3H#Y66z?^$yBI9oG#^1shOR7?wnJnbNC)TgOko?K#%ZEO1PPnt_T7ELreR9ca zxrLE2w|6)Wtw|$m@>OL9dtsLm>vP~w9hqz2tvyFOrffX|Mzeu6JMP&o6q*{fgK?L3 zf_v%yvUz!X+xCP4jTD{)vjiB@xCcH+%sj z(>v@~TJKBz`Lh|;_7Jq1CdMzrL=e)&BQBLCkIVOxVlWbqaA0`QV_eAIts5D`grQ#T zAdlBIx2#ldLHm4sf#09rZ)3zEG?!|ruaZ_3&~TmEBvW-kWpi*{2bKlZ&TmbO>@3_eCM1c(H83W^HF1u0VOir#{Ey34N z55J+SpcyxtK0zYu9Uf!Ta+g}U{&e+2|r@Vz*((}pfON-`RB&oS< z3VY`4Xl~@N(!4Pjg@2Qs7W9bvK z3bi_L!D3^+xZanJCbU`!`P@CiaCI4GOs`Z1BLV~w=&|`tw;C=->rZ&=ZJ%HM4zV3^ z{p!Spj7oCs?!45~df(8bY3bP2=6w8-_SBo*(!>+|UwE7XIB(RjUsP{+GVnF!=7e(np5b$8>z_;OJC_p|cVqHA zE;rn=dS@;Zam+sbP4IsRy(IbOaU~!>gc?R3oZD0U(WBhHmBIZaz^2_JZG6A4VcMY~ zC`BduWXuz$cNQ@y7olfHsP2Qw{k)I&##!6Wn~L;pSx&~nwy4taMGuV&`NmUe4yS7) zQ5YS`dvljj-b)*BLI`jk8<`eECmne9@}%j41J<>_F}aAHFGmeIxZ3bJIWB6z!fg$i zWHWz!(GkznB;o^`WSmGqEw$+5--M#cUrbK!hBV~d_nsTk{d!la;~5Wq&B!x8t|ATGL_XG3+E=dtC1ZK|?j(I&at1Kx2SK zhuzQMd`e}BK9PXBi;x`Afg;x%bn7inm$KQK_^14zy?)atUtZS0R*FWRYHw*Dxo^vj zGzO?Q8@=|uT&QGw#-m=bAtLG&BIq`)gPIr+@5fzL6$$-Be3`1;sxx`M)zmIZ?%5+aPgJo`E9;FRK7-XbDu<~4I0gY3ZM zRTOBwRq8F)N?wZBYB&0un63RNcYr}_1U`G|r8P$^kfJ;fvEjwYg7)f}S8_E=egoFLVsA8hyXw&(^c+Dyn>VCc! zp4RS-S!%RwtFY5v~3Jl-L$t_ALd-kvxG_u5NL3IfsN z|BTtRW|z#Z`~oh9mag0f2pJ|RK;CzAEr0;zzBVa69dgg)KF*5%JgzZ$W@XPDBL0 z6cB9tdCvF*QHx#Iurg#7G+eLR9>V2(jfRhDtH(y#X2xJqJflrXvlk7FrMnYy%T9>d zJCs`S_@0Lu-+b~Cb5WaCV7&lDyZ^4_9UPLD+$fq5p)|}Uv+`|Nr8b0Pm&oOKt5U6i zA2n^D%4|stwgkGSBT|M1pX%~3-oi2vt{y!waPc?tjEsyzy41NEz%`x;0*yf`t{NH` zA%Mp~KuzUw;w_lEs^ZJa=c}3kPZD-HrruIzKFE^pjzFjve5C~7SZPxj2 zz|jVPkEn{Do5=IYhSy*hW9l7bN?Hhk&7Eql_6W>2AzN4F&e)tCXOtiF!NF*eramJc zKqo_X9YIRkEoEtY7J`sIi|L*kxR$GKhz}?@{tqz7l6JV+$fJH?8QIP0(ySlZDsJq| zydJIlhReGj0$%bx1t{(unefP`CnPQTac7C^;9Sl!A3gy}5rBzp0vODmK;^@S-9g~u z%AoeDlG+irhfNtq#JdfFM)?V)ecW0ID}4p9)W(=qS}^ec7`$!5`5U|qt|^bf)Ki-t zR-{oDiv5L!4)%Ma|B$wfY-Ds45y!D<^AbtzLPDQ*m5Pc+ zK1L=Q{3OLNXnu`IqAO+ulM;+ocMnGp0HFc$S(dC5qN2k<^K_R*^gW+KBW3ApW=iSa zq{xWoeVn>d2Z$C8U7gL68Hmx-xRREZhD#UQ$3z7h;gOi(69ldsj)kc|I`!}mU7elB ztMc=M4-df?tT2o#54e$6TxJs z4d7v6@q321F6>ol&}R*q?rd6ETU%R}zfms$u5m}mnFkM9s3?uw4oxKv!SUY3uE$n* zLIk;pqERl2iUJYpIt%ZTyQ0@Vz_fZ-e`0LR-G z!ZnjpUgJFgp~!{Om?jMZ{r&wxJc*zX;L|1|CiWTtJB4t)#@921jR#UH!zYCKEkLdi z<{L;XeyO0}K!f}LAHg*Ir)Gn4)$7r8j3#{9kX-MPQNw@#Qw{!?Ee)jzFxF@Ms!_NN z5-=nblou6AL+Cu&?%cWaUbsD&n3so~TmeD{N`Ot`DlFLg3!5?&Iz>_97ZKMOWeNFGfw?0oBzJz<3>r%-0rtAoQhp zbZa}MMuXNk_-|r@2Kua#@HR3$&oG5l4@m~nE)%f8;(5vD@Ve3*_(vdRx`P}4zlk0- a@#Yb6_Ald`RtRFhi3&=+FXY$x{67F3YRb2G?g4^3gamgdxCD3C;O_o9nVH>v`*!|< zH{IX;Zpo37Q@5)7cg~GaQIbYQB0_?IfIyX%kyL|#fCNH7KrJD_flCJJ%y7Ur7%Oo_ zaR`X&IOJy&Sa6=)Oh!!+0s=$}0pTAE0r3bf^52JmaASvnI5dWU0Hi@c;5%ius0x8A zHY{{xEfp0Z7{O@-2v|rg2xxE$68wdLB!c)O4V;3IgM9a2X*Ea=2qW?(Aq|POhQKCC-gwNv zX?SpYAu-`^8itpT6CxM#zgv_G^)GFZz+C8mrJPZkFks(%RimmEn87c*xYCs!Ls2l791O-vo#T!kqq|0w#e&p+E~@AO}a z99;g{D{%kVJWZU~z!Sjse=)h*SpIkO{$E^wYW^*oleMF(ql>lUztQ+#)&Ew2g{RHG z(fg<7A8da|^BSAH0VPkIrZl;xugNdEUU+#Zb`M>#< zEiGLvT>oGE|5#N*Y=1@kANBtfA;k8_O8R{A+!QAbs=1k%oW} zgOHUJ*YJcq_JKFXolm({J;O>Y&6mx;GeqHqQ{XZ-+g_8OJ8(;~dtLqI&?d_}RIFXX z540Ot?ML;P=4ih&gn93;at7P`=3})cBs_$|%ljqtUjJSD-S4p#?^BPlm(%9GwRA*z zIXO|LFDQ@_T^Q^ztgpMj(I+?V^eHPq?S~E`+@b7LNi+1+4w2CKH(G zUN)26^nI`rFlj^vDwy1#!U>VenA#VSX36LiUkPqs8@Wg-KHZ#0d zWeZr}`)QB^+w)m|4IvX=^el?VB@kpSbgg{%%cj5&^&m$R9>2gKhZimU+U2Z(Ge4h_ z2EZ6eO!|2=>Z!ek4;fypR=arj=v`nVE@)9pi^1O@&2y>f0IBQU>ur7Y}p^9)=Nm zY)NU(V$7^#9_(_^QuXZD=aw76#?B3!%rc@{KY>aXn)Q53q;>LB}X_L<4TIc$*` zY7wCf2Qo5eI7Nvjv=Ys{J`~N&CBrh=&x=TW6aS*PbgsIC)*Kr2K~l15ZlJr4G*%k> zC!&BhEaLN=I4vjT0^3qyLlD|~;`I2V%-i1FY=pAX?@|xlv^@6mgPVA7a$X5r+h#PP zqT>$%oz*~wLuaORCI(Ng2P5$gWxTmVuceEGxwv{Zb~dp-D-F5FR*KjHwE8Q zicU}Dt_h`#99a)sSENx+5K>;8DQxha^t_N_5>^Dy`X3#svc}M0WwD~%e0sSbOL$e2 zO%`RGcqmcM$m2?(k;g}r6}u1<8OqGY0MeE@Nnw+b(ZQC)+Hx%ijV??&v|QlPoTVCh zVa_+E!WoYcVhhXlA(_kM>f&9LRQwFUzw%_g+7GxSnj3n=y;DiFF)=py$wbdjAv<(p z-d{IF(6(iMw$@$)`XyISWBOCFxo5ICcZI*-T{4i_#;T41%aY_IREK}QYJCP4INg4XQkd&1( zBwRO;%ucH4Pe=_by~}6e=XWV% zQBcXXG>&NJLvkJRb}xD(zmh)VpxPK%DgAsikh+NzNcs*Nx^QgR9}?R9IE~{_la7Y= zTeX&!*86nTeS(AoUFc6sJ(QBGMW}4)U>qn z3U+lGdDxTXC~@KHjv~0S*RSP{gb|RekQz!UcrbWs)axich zwtv8K^3uhWDagXX&6;_3jkiU$1OTmVy*{KQ!?it)Wwnk}n+a5%bUXk0u1lU_QS<2r z$#?}%C&mBsc2V6oNx!5*ee{%v>Mq$0YqWQq8c$HNnL+ltHqF!})bKYObHFEi54l!% z`(3`ymwIaIVmYWsNnc-zo$|g;r@At#9Y@{~l>@!s%FOS*iLFmt@3{8&%r*&T12qb8 z?)LV6CE+rC$usCJA}~pmWME(P;fi@bo6G2Co>iYsWyq{1&O$(5c z6=GlzSIT7W66@+#(+E2rA(k1jjm)cTpm;n)@R=3A!^DbB_u1d@H_;@?nX6L9eHQYg z%s1O26;k#sH!q_LSHi?3q>^@Vv6%j?czKLmId~5=%k5Qrxb^6ufkz`wgg)DEa?AP%g^$3ca};b=po*HqZU1;p{K`!O=k$Z6_b)Ug>;afMT4uUd474d3E4Xm zJi<26ta<3>cDSH~8nhV-h#uBf>_9S4A{P*V*Z49~{0@jRGdW-j&?P~$yrh~dcqw~6CffPz}boG^U^Kvphq zXprl8ovkdRdrr!E)z9|ZBpezZ{Q}(Xhm4Y2Y_PA@(E3uq5@b#cpht91#_h zGCIm!cb6MY$fb1ROjbpHfrGm%ucj8MFR+Q^Mt*y;{Np$0W+Wnc`AC3Gv)x*aY>WE| zlA3Hv{uXC42hpvMPEU}Tlo74Z(`}MLtGjrt8bA3EfsE3y(zA%yWw5!qYr^$4w*aKH z)5+4CZMx{g!@3XZZ84YhW)!X%%FHat3 z@$$V2B^W+!@9kT>@0b_cy#+HHC#y5 zedTrg_PpWe7pycTe%g6vh!Qy)X-4!DHi=d#Ip^h&PfgX3dj0;fQ!eh9l!!77_4~Tl zoq^X&%)?ZJJzZI73C`Smp|OT3L1Dx*(BpM^ilmX(jix|$Ds)^^jL^fi%Mx#>R$9k% zQ6)srE8`tct<^~2hbZ&c=Twt-Tk?(uO67e9dT`-^&myuUaf|!Z2cP_B$cCrNzWU0(&oun$Vf%R?oQ}Q*= zyUz8iaNi;AS6gI@4S96oP>F)O>mlyzu~{)XE`Dd+uW13?j|-D}TUEx;eV^4wIUN!a zv1zU8cZob7mK*I;MPDD#`5UR6_ADXnf788&)o@++a1)(WR+N1)dOh3&vJqgzJE z*T-y64z!w^myY)Bm)NsYndj&utn9Vlw|(A~H=5FQeL^GIIWl_Eet0rd9=2~>Wn6A> zOp+#jC?9RU1*Ho`^paKVoh;R>`@T(_O}q_^L?3V!=2nS~DD*P>q3qnRlKC<%>bCD> zx1PNZXHu-rKwQcB{K$Z^r8n!c$6Qe#zMu6OeL=&~e zXTa7T<+LiZSZ9;;`R3>?G+n-VG?PPO+(H2$AV5fw26(vb^ixLbp&$rXAy1N^G_gLK zH($K8m@1HN7$d@G)h}&mbyx)~?0rTpC@gO1Mqx9`_B@m&j1}i4tW6roxraY|LTdZv zNrIqDE5Uj#lHRniLR%Cw_R$aA08Im+|NSx*I+a~g6rt3QL=`uS1chuN!ffB_&)XSB zNm+s(GC3mc99$#)5GsXA*xTJKNmWen(Dc|(G}{{rHf0Qtx==qYeZBcHX3%@Qz<+gI zZlL=WHceB6$J$cGi9S%jC$CRTO)Y`8lq$I;NZt;D?u|Z}=FnqQ_px8B(S;u^Q!gps zP7OTA?g?Cf9`oy_5@ig6N1tmcQEHa6V(Yx0t=#ofF%q(@Hj|E%GK4-B0MIvP9|wtc z-1OqUbG7bQZicve-C@?4lF)6pjA?uLeKb|!V@30^A0Zu3=g1b8ci$I)yOx*aHNcLC@l1~kL|NAJWT(*RKVb(Dib5)j&|p3$d`eTX(wjO zgDp*DFZPcYu+J2};&E0xGAA33oma)?R(Eh^MtNMRS%QtSmX^htqvP5&rWeXWH_0IS zXCj@m0i)olujr-etxs(iT%SEgjbwa{z{Wg zRA`?k>&^MkHhhciem4SRK$nZ70Gob@D>dq8w`2ruaX%L^V28k>&*QafGa;E(KA#M_ z$1qM9P7{Bt%?O@=x)H5_be!=E1-8z0eB1Uw%Ea5r^18T;P331o=&nUBTu#Uf57S-@ z3T%9Tf%50R%C}%nISZCpl7afv7?LQJE&j|A*d2P|)JJRnBYt#h0kq)p_H)k6I4BaB zr5|JlWI5W6)Dy;)V^T=CgVPi4L z9B9c|qf(hu0-w!lFoWo=c+I5HQg%mDt?8q=Bxp@#I>@WJ6nfIpt>h^+FH<{Tb>RX^ z;cv-um0-P0g^9n0PVW*x#K~C&qi}JylqYG0b>DN{tuj5`o`6pd$~?1cF7BO^@R z6nj`Y<&G($uQ$CCo9A7lt3-Q5yuxEDj#=x<@IMhg5~IHFHG5#X#dRofvIIU8kN7-(oT1I_ZK{-sv3z5V)_OF>B*5tP zSsMZQvub-J>Ea@zF@Ke5S~&P5RpK7|P}!c}Nuu!X-O#$7i+S{R^Bj=OjU31kZ;>Z> zfT_*5Qarfhp!z&C?u*8DK_JvEa@h<|iot3J6lW(zHektWiXW34|H?qio-}PBXuYr9 z`OFiu&Q%GV!~MZwiDixw{@#NpcQHDKOW;H#N<{{MhwB#-avPFHLrX=?DdBtla&{=N zV$&_Dz|G)5=>u|`&#ARCin4iG1+-E>IG3O&SU78Jyl$Pb_ihT>kV^O{ML7{adZ^X) zPmw*p-kGxqz1^M&?mrHCylr<@Eg0qRH3w;L1S;P`ri#3))L;YNiO39KBjuM41m8gw$uZ&^bGc z6ZtkaLeDSqcDB5pY=aO1ghxF|b9-WFCb~^hI7_ARC+xO`GTvzP{?4(^aq8^(i!e(N zendWWey@;-Kt9Xd((+(jq`lS^G)#6h_hSx#goMoF{wgZ!i4moww_#tQ#g@H>u|2X< zHnk9vsrGW4YRKQ1o|1|?Twhy!=;}B9`&Wxfn@JDIb8Ok9?u4WLtbhW$U)^kbn%(#$ z_3icaZb$I#_H2ooZ)Kw)u5bX&y`=$WdgIeU5PNh+3xs1oYcu(9uM*CqY}}{A8N$o1 zm%9-*j)I5ZjU55}&J+!)sFPRwAAlP+Ws+OpLhs1J?(dEq+kSAdfI1XbaGkeN=P4S5 z@s3Cl0lP{)KdC&b{2i*A1a$08nYM;Ae@wov+#Y&77kj*(y`71=HTHN^XS1Vo*t24B zDH$TZqii6)UzxG)2@GlXmE#t@Uq2g3Qht#*iGk_I0g*;Tb7J7xRrI+N~6^f1NGZRhP_Ixsoy7F2$1 z0SF{4y}Sl+uno5lM_xvU1ZdHpNA}pS2-)7A3dpPpq69aa5(3I-IHr(9d&&I5)S*yw zDL63|@axO~w4gXfm^@87Z57~&H|4IaIOnSuf zHh(>hR&RFp*z-eWo_g`Oume!~vBKycsPEA2$gO>=vGX-6^anwSm9!<2LGUbe&NbNr zUc?tY*z$5YS4HEwQQh~8o+Y$`!rKoku|w}h9`DZBJXYK@M!yQEPfbs``2+|$!J!l7 zp19a2N&-jJjG_EoqVt%9ihwv&dEGE+kd`0U2W;vo3B#s zcaU|}&oD+hK~6Yti}+!is(%Coin=qS2icK548{2}`rS|Gs3^CufJRv467|JAXN`VqL0@2`uUTs?Tp^< zt^T!k?<&QZX_l4bz}XiA?^|P-@gS1{|L`7+fu+g5}`X%CYeW=e9+UD1jmf6Ie(fgC>agit%T|o>{$tFb5 za-$y^ULhLvi1p1weJ@He-kxqLI>yr+QX^^YktfeTZyO^*CDG`K-S zaD(0&j$)g_a7@}2GcNdb+Jpn1e}zCxJ46Gi>ilcwCD^>$>Aq*c=+I^9Ku+=SD$ z+iCL^8h$6v&kF{?{{wk86SlguUgv#}u9g00%AX!lCPuRWWV5uBW_-i=l=3xo)lhV@ zyv-aBeAbD3KKN&8GH;kx=_RuxGJKvmCJGmivBwxaS0fR}DS$IK{MtRy{vir`wB*wr zdEE`hun_c1{{e)Y&+c9h3@s2qRp`tAZoF?}K-$xZcN~Pz>2|mZQ>UmS#%InEjoQ`!*^c)fWLQIo&YWD5VxE z@b6#LjjNoee5b=s%M&6(ekTl}Y|;h%=fJj`+jnWFmiZDSmYChW89IS{B^$+vr>NR& z^G1&-!{tVUXz_98hN1CDNkqQJOoZ(=r}LIuG0Y`Z5}S5GwetQ0OZ6iYEPqGRaP>nO720$dSVBbM|U zsV+)lpv7yf>6vzQ@ST>Ju=?5%#YI1#cSu99_rM<{k^{FSf4bt6Fo|Pu^REs6%FwyjM`lb$ z&XUzhEA&Y9`-U(*Xt&Cm7T{ByiDkdqph)^QPyh=1P5<3ax$ioDtuV)<89`J5DHaIK>&UtM>duhUWqBdrNJ*eiKE5 z{%R%7B0QddI2ZP^lAR|i&~$6HzU4-)I-j-569$V?b+TtSD{XQzq2(s3#WXFD|1y+( zI6sJOOuYU_cGGltW57V4b6!kiOAm*bU<4wtu+6Tszb0XzVUmELV*|MMBBQr?Q++?+ zgw(Dmk{q!{4v&<*&HQz%nVUZ>o8xZUrD}&~1#+{$xmiWdZnh%;z2vRP0{5G8pk}B` z^C>3us%oQyL2ph=erwAHT*7wih;gYPuj#rI5BxOI(NI`NhrPjYZJ|Bb0WTYr3DIVY*z> z7EI*iJ)Tm|kzTZ}%T+U0L_l+D|N5N?KE`sN z{4!2glCDF>>RgCnr5>Xfnv*G|Qg0~3HJ}}?XR@0qoj(_ zeGDZ}^E`}i!(kW`UkJHWGKVYNSUMbh^=|?m)3g1a>Q*TwYc+>1%T-%_gEk|hJYJIg zJ*^M>D=9{5)V`;*nNRe%(r4jRcEVC0K}yfkm60U=YpB8%08hnJ%J?H-R;#4oCotH@ z5(6>B!-<7n(6hxNY)QhR_Yomy{!Uqbt2YZytf;ksuS7kD6#a@{*v)h~&r2!xg$IoA ziIR7*V?X_9ZJ$Ete2@*5H;vkqh8;~0eE&f1NSoS~bq3nc{BPQg3iNw%Jkj9^XAjN$ z>KodmKhPmmDnFFeR%#QJ;%6|3dG87a5MK}GI_uZ|0GdD;*7mffR=#gM{^)jBpJKlK zQ2L8>j)im;iEoiRh;&i|i1_k1YU{Dcy!DK<3-oZ@dO)^_rZ+@?;7AOyt~=_nmo-dJ zPc?#9T)drzmaU~i_ugMEH&Q!4DiE>%fRo|=owCHF{D^I6YmSPFA<-G^+Di>*TMiJ8 zKvdFkLHZ-D_Soew7soiKYv|SPh2y&fCRh}e zm|tGE7HY3!HoeSwh2Fp$JPz$_>+X6cTX#aH4($WyHx`*ZpJ{yGh52z>>y?4kpKoHE zSJK6@_t~`V#g;Xl7uxAORdk4q`h<^%*{<~oM{Q!S{WMZN6*_Hz95-n&|AXg>*-~yC zS(WgkS4Z|wBPch}O6`3Y6etq@$@F<747P6^F`k4Q4A$a_cg<5P!RS-&-M$y?&3RzY zUiv7~*z87{zfP%TlJXZA4)XWwVSq$9IGEc0QSs?>x?Onz^4_x0xz>Cj;-6XlGxDS} zi62-3RTl97&Y3>UbE{xq%7n;2;b$Q*=6o02&-oXDJ{JWW!Rvg2to5IE`qP1P*%LaU ze+}eSx=Uu|4Zw-`m+@Q@oWmv>`3I=|2F9MBjHGGv{!(UxbKdXG2*JZ+jBrl|#;f0s z2C2ba0;AU0;GD8GbI)HO`weVmr+y^q!G9?|DZx3!7>mCU_J$uA+J32|N}BjfsSM5u z=T)Qp?ZR`Me?rrnn>p5B>)MC_=jgb+n?@q#wxu3OZlJ9{V{_7%-ny>@;__t zKM#~Yd+~psmVaEQ{um3-sEm>KxM5El#*9R1=R70Q6% z7bztI6d4|Uzt7KCeq{PuxPrO48Ec3Ck-ZwFkL&s=Il@QG+qQBfIPsCUSD$)sXSOX>S=Q2?tU8}1^yb@t>x z55E#^s8Rqg>?JMUS8C)(geOm_nfiQWh1t8B_2RhH&(cWNh8Q72*|$LpJw_zURX2PW z5fIU!YvlJ)ISwk1*MC8&$DIwY;mnrq&_dWM*E>>&Xdiiq!9H&uJx~2BVz3$EO^Gsc z#C^~b5hFBWst>ldEDAzf;ZCw;Ns&i#{!i@3mzNh#h5T`KGLC|*Dp4L1-1;WvQ)PrN z-+Om)O^3t}-a9?)1V5kCNJ|d%3g2%kznPDl!y^QhD4;W-yzNJXZ6}3F=Ss&A(dp_6 zo3D3dVUeZgnoIS}!1PU!C*PY8bGPJj#!Ew0e)~4ZX*HfrRUmCdd$Qyf|MG;M`%GVg zV0dOY|6M2>1Cx}(Vs}?f-GH%6vC`%;Y<@#jJG9?+*hx`0$#x;)y6v~yCq4?aF^K+g zTG=B6V%0V$!zyM{k70XX)Qj?R$BFKEO%)}m-YJ8Xulm^co0`16TEd!ITC!47(u7== znK-z(40-BS)K^nelwcQ%Toh6veKcaBo%o!&eVmFLIN#PLjxdX>xF|F1EdHPH7rT${Z!vCmd2QeILARq{OnRi`3=Ojropkt>oRnsjUxsbd&Vx7;ox&{?6={Sv zJwL<;f7H`s0^a}PZ9dt$X&au+Ylwn9&{Xp3Zu9@e+exbu{G}UosGJkRqB%T~M^&Vp z^IGFE?l4NbmDwT23~le~Hmpo)ZUjKRWMyOPJ(Jm|R9i0G+eT1E4p1t0dt)C@K-R8# zv4EYp+f5AVXtEl2Clt8kG+u=(r8__&8961yn4=M*GDU~xu zqk>ol?WDHcmn%RCGt%(_|MCYFD8zv>t6AMu!m=M_UnnF(W!F2P$=^sLB4XUCHVy(t z#%!b=yhQGUY4rpkH9(6Y1ix%hlIWCGEu^HS%S{&r?qAyPzQ{K^tkSTs7-)Uhf$E9T zq$>OIBgn%Ut{=Bdz3uT46cHH%WinensTQEY5_9Y7Q}lWBYCRe3P==wD2lhjVuRoeA zlTL}pC_ zOoe04+ElUPx8tRDc?;U1zTOYzeB-wJgtK{L`}_T49|Y)KP?wacOy3c}4Udb`Hm-jiW%qf!bH@DVD{%DK^) zp8>QF*WdY9576{7iV*AQTBD8!aWe<)bfc}-+u{?7z9I>@?#E{aezsq>C~fSIohnf# zBjh)ZXV$-`m0>j)G4@W=C#i|U#Qvg~uET5LTusMomvaaMq9C{i9bitoY}G$29!KB{ zw>i1aGe1Q20Cx*|9JN7RoqP>vS(Xu`OXtlD zX_S^d&$9n*CFD9{d!I!i+LzvfupiJA&tEPM&}+2o+Te!>va-@`afO6SBCsa|ZYd<0 zNSkVgHB_C&w=LfC+N+u0SLHcV-<9Iv;4t_yr$$&fxtIAM@~g#8cV&J_O-y9=8gSmJ z&3UPryPEG53GQ}m!dmk_WBDQ;1ZSJ0(_oi3C%w4X5BB~g#q-}V3n5VJ z^zxE69`NY#T`nUj=_#3|wAWPH8gdEG=dP+&p>B0a zE}Qt7QU*+ml-7P-BI>JgTz?&AiKKai-OVLYA#+_Z-I~-^#h1|OO?4k~c~1P8(;EN_ z85t{^Xf_Z#G@No4V25NeRXCKGnMqmMDPpwLc$V7_I&07AYd#LAq*K~Cw6fEWBr=9Y zXsD}Gi@Q+P(TOH9BEcCP>tQe&P+Fzt;ZY>@d*%@M?CE%}uT+4fM1ubMd#1B6ja1Ui znhthvhQd5PYZW_t7elxHQ!_cvE`}&d+;o4UsS-z)Quog#^5Y(MiE*$qYnOjO4d}*s zX5CP*UNB_a>l~GarJcoG=-?UUAtOHRTd3R?mAI@hNE8xcD&h=$SBQ1E}{S%R(gjUJr)tUz>YEu(G*<0rK z#4Rk>#OWm_ z^>*q#OsK2zTvfR`h4XoIT^(9L_pm^YJq-u3sIMfthE|n$0;B;yMc~d(!_vi~p-hb5 z4SBK2gb&uae$l)E@Bv1^`8xb~p@XntNh@}jbZ+~UERp^jphAa(M&}azkfcWqb0HN7kA%ku+p(}mvhhDkPxganK3BwFJlm1J$?}iFSI89NLp90s256%n`8fG>-gIZB&sS-U&Ykhd6vfjk*%SJj=+5(J_^;%n#)) zu}u_|5J~TExfmpe^s$*A_RVny1@H`~v=-Y1dBbs0w<}st!|H5nO)cYidlC-;P*d@fisx543L4xiZ zp}*q#C=K>8+O41eEpHF{I@$(m0Li4Jq;Q_~;hS$&m8JEgsYCf}1h5S~&l`qRo!#+k5958Ls?XG!N!ehRIgp~6oQ{_pwSSNMsg96xng{EnnMTJHl^8LewOuvr>>Q_-SG0Tt zc$|LEN_b@L8a16Rj;TuFAgEFg_7uL@QOCk0qu4D9L1WDVl<(D9{$kXv{VgmDo6s6z zPhBx1yey-qQ?)@7jNm?4XH%sa?F-A zwU!1Y4X#Hl9_F@1^Cn2-qSKNeze$MDk{gp#UBDYBSWD$Zx*2^AYxZ& zWVz6e@FtjBjb;9D=QrzDs6Rq|$7{SPKm^!FGgzY?`NA@*vU@YX4)r7#dLsP2*LU3~ zM?I5^lXGH?ezD0Urol*B?gPApqW`${GHi)bDT;cr!t7%)I6&uNaZ%V6Z&akgY{0z? zYAq{QdWnN1?WnR-7j$!sT2esFMHNgQFx%!eo*U}4u<0}5gP$c{tC*?Tana8-7&Y0Z zlm%eetT1eso2xJgxHT&BoH|{+4I_JPPZW6?^ttmzTVF1U(@V)U@;d8q=E+;>- z;j>eELDo%&8irJmIzCgb?TBF#dc0JRO?AzY?)?&T{(fw*{K_b_W_!0n@GO9?hSYEz zQS1pd37APFWZ=%*RpQBcSebss7q>DliK5*M> zAXz62oh*hk_6UubNXWt>Y$LZ3xGhDZ(;wZefJP!xdQ)}cvF6p;4>HSzKFrH+lX2)c zkjk=T{a7c4M!Hg&7uFplZQV{C3>b~nsx&Oi3dqVWE!6?t5t3_zqemV}E-kKp$1K^% zCOeoeQlArYRdgSlV*pl&M$v8u3BIc;eC1YDZiTbO%amd>XpsT7T=Zw$crp$qa(#^) zMhfnp+wb!E#pA-xBv^4mPU}*cQvlQ%TWWDVphd>(fNn;bp*o((Lxu}+&PuEmn@fO+ zTSA;?33};88ZpZ^aP2ofpiLC&@sdwv?a4Q+UfO`%kjru=xAZ)1-#xP_E!RF1A#X=@ zKA(UL7Gq5+Q@8pi0=nKWbMJ3cFHvI1X7>9%D&Pe1Xy)DsWeblsrfukzVkl8VrwCsA z@U{R=es?)u$EEM?NujzlR$}G3&`YOvHfX-oHpP3{K2r8BbKh-KGF{Nf)Sg6<(64uf zKJ&v55#=v;I;NTqK?X)Rx7^N_c(B>uy+Vi0khZ$JJ?7(k*@*UoG&zXN!O!JSKFtHM zj2CVvOC;5MzRuVCs*81AwdQ6JR{B{Hx9oNc2#Znfmt^*ddt9r&Ppg0o8V5S%3EjiW zAl9qX&NmS^KM5l^~14?=0BF z>9-(kHOv1@?Fw>!<}>+e?%IZR<7z`4<@D=((=3@BFNmPIw8rO&m*8AsX!!I8NuuP_ zVfj^^5D1eRKnp^C{-qITQx}y~>)u6R_(SMv1)E*PvkPF~H$#>-m892n&TwmQ%#v?5fF=j9dnZhg$O1ncM*3K9XcV=u zfY`#i;FQ)aQR}%md&IE)pTe)6&%#3xLt!T z`V24Z`0|K)xe>G;_G|`p$O2`?dW3e96b=WRrD$imUw4lNa&Z)oo~BRS;RWMGil4s| zgKD4b=ZLg7H`fymgGR3gU6z$>TBKON5PNcQc^~F_&>FuJO26&C)z-j+?7^DtBl7&L z06w*USiUO4If8Bn-Aq+Mp9DqN@(018CU~s-Kwr#E9|lzDR7>(YEK#n%&NZ6TZyjM` zo;sug>ULpKP@bT(S>4sU8^WhAccVDAoemi)>8Er*Aqw?d<@mfmqPVDGE#_MX8MOB| zoGh2=vkI_%tkHQi!!Q>e{3Tpi3q5C`6&jCnTp{xJ|onm%z0gc?lJ- zZ}v75Id&127w6eqN|^t#u22mw|3u%#cnbh)fBvwSGqZjjzj|IOaVj0iD+?b(!Bour zc@HItfb-^XRj{c-?A?Z4^xBo>=fr0^#q7`<|Br+EtbR|+WH&^iQv}j`8xqgbail@% zejPY?Su=!2Ha2BKFW1{32VOY5f!Lp}PWsiR%}d-pY4fJ>otMK5L~Hh8^*M)!U;7aw z3|BAxo+Y*%ma{}i_YVWe0)$>Eb(}?NgrTZX3oUVgcv6=s*gNNeIH0jnN=y_P!n$ zdWkbry6w7_(k7g2xBTBg;Hcu;{lNN;QgCWT_h``Dt#RYeC^nHGv{gt8fhfNV#OQLf zJajH3+Aq2v)xNFkx--Z`z|2y$wjQrHzJr;?98iL+ge2b^I;^g#<;awnve*v{LXry{ zNP=q49P5WV_%&tbiq(*2;*U@Y>139GE;XJ;GZaY;0B)JzBnp&1ZIm1XssIlP}9=#QT9Wd!FilB zHoVf^fl3n>I3hW#xfB~7&D0_P1h|3S>n^J>L)XI_eR3rTPej}AxZ2$)zo!IZCX5P2 zq3Ro;8wAH*nP6lFG~GecB+ty;$_bqY@+=Qv6eRuBxb2GD5bIjkm!U1=!ugO@t5_Gn zn#EN|asXN5f&b*GNNNcb_P!0=28P^b$&I@6pI6(5goFt7b(6CT&Oz_pOC%GGRjq%J zoo7|@As$R$AQPZ+O`5(V)`*~MH{%n?lPGz^@^oM{&iuOB;solVXJQr6wAk7ba8sO* zvv4}1$A5K0MdV%TXs@N+JdAUzGN`W7UpK#Hb#+r9_VF{8xilF zMK7Dg5-_!X_}9AtUdT}`5cXq`=;HH+Xe{ms1c~A%GxEh>x9~;XYp;`#<5r-L-CYxj zgkkUaMm$DbMG3zSjh#}Zr>9?LtiLLZn4r8K64sipE~S7(gzx1%C1mMy`n8|}e~li~ ztZw2y|K=Ox@mPOh3l|)pszM!c7et9k8wPtR`$_NduSpsvCKA!dP6h3;5-Ft;63NPa zS{Yx%2t?iPz1r)9oBB1+l7<35;R4#rns~=GBvEd_RS9?wUh4Q6k@tQ*q?tP?*GyV% zVFrp(tJ7}o;u%g68XnH%75bsK8kC=tVcTn6xbTLvm>e>6Agn?uG`mBI@PAp?E>(}u zF{V35nAHlM9+<#{kX}Lur3i%-j zO}%TA?MWKpa2`*Q_G4^%;U=v*^=LyyQx{Eu*xQOAAmUakA33gTX}Thd=x~3wJZ`Lu zAb$%bfwRJ>Q)#?pwZ$iKAj8*VCr^JvnEDg}c(^^MPP;YW=ipr7fi^L=nofJQXtW}p zJl|`ECkJI^N@i*3hKrvMYj}ZWa5g~}AUdz_A|ckn)xui<4R?etFqB>$N{qmaAaH`i zT!(kq(qE1c=gd@;fyGWf64?3*SBguJ`lS(g|IjqTnz}}B zwYl{{Cwv=^iXJ5*vSe=vb_A82;v-GvF3zzWXU0WeRy=z2@s*YyCAED<%7cF;^Kk`zY>0> zj5D|IK)JDJWUFO<;h|lGsf=<|RJLA&-PG)ZZDqT754ITw8h^CsLHcw`Vp{(}o!KF^ z59@w(=V!_Wt=m}!TC*5OlRSr&ri;rEw$F-!{3XTu=0`JS8KGDpvzC#*_pDlI`=adO zOoG1Lb98B(RZSkVJ7p8|rZi%W(_=ZJ%6BKuo2fe-z%6PLZeBZ`AIrD~KGi5oP!c*0 zrldXG3IiCoE#TL#8XT3}h( zfm$>nzP;~Esp^DxH5R|H)tCZ0$x1mivIHlweiVDu#%Syj`wD1p@XStl=8S&eB&T;Z z<%2MAm>4%sMD^Pq%DdmeJcJ;-Z=L-|?q*LX{MTfxi@h& zfE6>6W!mgmWx3H&b+l%ZL*Qh&+KkE6iH=)psnvsw=2n}Qg2>_M5gV(9Tgvvh;~dT8u2balJPp~2~T zCw^3%x0^{=VK3S+17%55oan1Z^kZ3zXi5-z^=2TtFn;1iEQz;k4)3m&g3I363DNwX zYqq_mw3&Beq8c?97jjL5Dw{9`g=|T#0l;GxcAYNgS@3d79HNN{J*%psLOiyhUAHMT zxF4+-ww+Ny`;KDg{h0`|J&i0-HEze_`5tH>R&X~OkSqvC!1UW82<)?eupwI$8xs@O z*hq9~i7?O8q|@JhSd^Nr+3u{Z7)RO#Z!vMT1LY(uO961_`&FXIo-lD}g09!-FtuI> zADY`lTVpeV<~mgoX$Fvq)PqPh+vE^{Rriioot;ftVu)a{j|4}V{A%JV``#vpM!FDF z?~g*G0}6+QE#c;_K?UAEwoL2TomDi{0ee2i1HYTfn_5M;aIqJ?PE9R+Voq&UZ_=y= zYa`H)9ZH}I6NeazM0e!gB7H=OhpyfjqgsOMQC#trk@hzs8-5qG3eZho{3gM=m0+jO z+yWUjO`snRYvn@CwdKcrSBi#UVL>A|vob4@rD_C^ei}fmL?6uy4r%rl_L%)v6iL^E zF(a@&si3Rt;I|xlYiXNiyWH@ryJNV;46|dC{C&+Xa~yK-_c+1crlZIgx4To4kB0LV z>EeQx88-O(O_nmTt*+K#w(}K%N&P=ENOWdB2yIIrDny;N|7PUcp`+iF|HJNRfT3|xaQnr9O$=wNcjGE=b-Ti;qsLQf z?OAIz_1!I~ApuZq*yPt@6R8##8#TLl;dkF8mdcp;#tV8j8VsJG25Y?{V-z6; zq{_0m*cbtrg%OJ+Zfm0z3uFWp^y1)Gkz9nV?Wc1VHTH=dcV(Zd=pob#5@Ev2tfBPx zJ_&f1aADv1PV=~1{N`Z2cd2s2XX|`9c?*ug8>WhTmTf|f-)aB+K$*?>c4H*kWnBLO z9&qaB=-UaoOW&8Pye@d(1W)9aD;j)Dv1SXzi!(IdYEE8Ei2{}P8msCB`C|M(#JyvC zB~QGzJ@Ld?v2EM7IhokDZB1<3oOoi}wr$(F*MIN*e1_-6de^JEjy|fptLt}OXC;{X zRp2BYhaK>sP*AdJx|)KHrQt;{@D0H~UFkS)a7!8iQFW1@f&E;l7#Rdw06) zl6@}iivw5gm=|2qy_;+|!&7*M7XLy%Gl++|-|XQTZaudF@=4J&o2;}PO8hC8p02vf zEs@c*L5^{hpa}h7l-1rX1V) zR1IbqM5#OSkOFRZW&MZKj)60JB@Eu!Wfl6Revoix2nSJ}k0~d~z-oEmJSwbPirw}k zz{XfBeH$EOM6Qzyc9}XOXSkeg+)QiJRZ-yVvp}2v_dUn@)=&CdjrDK^uUvO&XRiJ zu*iEw&Vaz0sTnN2P3T)ebSvj9yL%J|jl=mF>jlNJC!n8j;k02z+n$#}LUIod)`2e^ zw&x-1`{Q`K{C*H!@wyT5pWj32nPsV-qM6(UJz?7VuSKltLcCgn&^0u({tLZEo3Sz< zg5#D;IMl;PM7;#g_yD=v8f?05CN=?a6UJHh^o{KgeDQlH=qxDE@MBg!0<%3{zO9Q4 z@XaoT(5sDc2({!E9fm%3;^Yd!d{^ZJDF_01#7kGg-jW)TZ?2w0{R}Ki521WRJ&`iTwJ8r_Soew(aZ(+z|w$`?p=`ud$1>;n!+) zj@~Icj>ahRG>9{!tlXe5p8LaSSva(6?N&H5(NlUrs0rsj7Q1y>=n=3G^%wRAcjR}8 z7SuUh)A%5@Xzm0eImdTU?Er+WmWNI4hrZC?@4;r$t+$!RJa9%zPh{>+yRzjp3XJBj zRY0bs-Dq|fdwR@w1IN}HhNwU3D3*x%&&Z-lQN&CZ%&l%k_48o17fi6L0qE>}NZ5B& zUnZ32$T_Klw58dG-9|i-F99FJlTy^?VZG37!Hxin&$h)s-FLGU(FW9QSwePfm{%4L zSpsm9Leug4#5cuG=w~|RT9HO_(^i^}j5VhQ?=E#nKTpzwfQx9R{i*&HSiVktb|@{y z;^0}GuF>6>T2@3qj7+NT=7o12`q<=S2TOr6huwVT{s7*l4+DxnL$g9FykT{QLu5dW zKs8>R_huSvx0C0%W73J#94$m{+mgSHVbmt`)VSlxtOH0!_p2%|ndw5mJFg!LQ_kcK zLUUg0@hzr^E-!EyX}sAv9(kuH;=6@^@THX;g%fc9gS+!?CbKh3_WiuwXD`<>xkdb) z3M3#qMGE6L*w%9$??qJ)JxS;<$%3e`(2}84q+lZXwO<$~Eh>TSa0xer%=9a_(BZa! z$RE~@wmp{VptCqoZpj(Q-3Wv=J)th@cc&uaG(da@_00fKV3}ipzmd5e!Yfs$-X0iw z2{NUkB)2jW>fZ|qb55phD9t`j)9*ajU36iHm&sHr)k7@ykTm(%{n4y7U2lQ>vv%8E zoo$Cv&57GVVLEucwR(Q{MC#rN+rYAkL!mNH^uMOz{z`YKJ=cqA0v6M%tsSR}6(x_e za*8@DGHK=pW|Old3wLZ2W>?>ZJt0*Z7Xlu;sl7?OhrZVzv{zxd9Cr9QUFv^%1Y6$w z6NqNke*Wex{>P^OcwRtnw|u=&-p(HqGmS$Oa4#gXq1l8HYYMFMHSkMD+PUqqvpnZ( ziiE)}mG0hRliQLR69?RzKn%8Mgm8$N0Q1`_)`XI^#Qd{vB3{93X%7x_{-SxQ){_pMc_A^V*=OFVkGa63yKGOrzMkOVf&; zMA{K1w^QndiajZA{=m%{8X3^~`-)TwkF#`c3+{Okm0L5kFZ}ARM!uCk5|#~=s~~%w zvpn!l_7H5DjPekqyDt+Gh(1xd7cz--c zBxVqqSl8e^-1MdGUxh}7=Sp}$ch+6b7x98adPT_%#faNs0&goj`(>OPDhLZqXl(WQ zYq?xY_`5~jscE!7Uo3_Fy4_W4ce7JRne2b=hRlJUq-n(*9dLSqbKcr*cA?3=4B*js zNHwy~a%$dt7r}c-$7xQ!$!t%K-}>wmyB<^K^{>$T?geJmIT~Od2eRqxTnJx;X|oUp zH%r~4)A*CGqlUH~pWH8~=TNzsIVCP?h;G73^qFoIa1ASaU_mlUfqL2-1~?} zJ+3w~A5glws%Uaphbh*hak+kVy$sd_9Z-PZzf73(3wT~{;r%RbMBJFVVzdCa#oz5R zjCiEQ*~Vg-g{=%QN|OMk{$>L{euP#bAyo-!bQLvZ{^KG}DFWh`=W zhu4b5_txko^|8b!@E(9-J@vxcMZTAcm2ES_^_p?c<@3cLNx9^&PI`swN6o|MxRqn~ z7ABZS;pI+KXgMehj@_4vlDHk{r_bg&k@Z)2ml?dbF4=J?^)5Y@VdyMdz8MX@0)AFp zXL{^LM1ER$fM3K+rVMXb!VwDY6?0r%ogM)|U!4W`FJl>~cPgJVt^vm>ebgP~b{j1; zLKlF;*S4cIUqn zZqS{dr6Rq(Yxs$U!0viwE+dI%41cmtacEUZSB@arBkBJPVJmU7r_dk<0= z+aG6b0StKH%wze;-rRPOyy44tJ?PWquuRTCZoB*r8GbL|gtxM1N|@f-`Mc)g{=@;u zos=_6cLQg3=-C0#z7Ftg2_8(rwYX!4bnbh2aCrg}w&?%dd)y;paC42^l>5@ga**7H z2WCtF-0W++>0|PDy zEO-X0U(J_~iW7V_s#WUmBL{i9MeY?L&UTQ4jcOIgF!^#{x3I2zW&1Myp03to*Kf0j zkBL~G!}nbEmirB+iVLym|HZBR&IXH^{|jI3h!6o&efj$JiT@k+`Y+z~wGb&~@?UbQCrr3U z@~&b+sQmvl)RPPBo1S&&jlA&Jy-FZ&NA$+^ zX<~xX_$SikchT81$rVBKUxMeirGW$GluzxJ*MxyrR<14;CfwK(r!O}C!I*9a&N?tJ zm%aN#9epuY1pYj!?UtD9d>j2tUXzv{Px=GrF;S0#+dqp=*bM7_)xrDTj(PliGv`@O zI?u>4O7wDYK!J&>$`oLP5 z6u(kaQ#qJpGi!k4SH5j?`ip8%Of;99SqXxo|K6R{9rnSFNC|aiMHU8K%bgFdNwD^~ zS;RdAf{DrDny>m~1;d({|Aggjlzq;yo9mfBj`C3PSF+9FIL8W9+RGhWQ!?TcF+WC1 zpZ}~zXAUCazENNhyGu^sAD%~C!1)MLMEBZ?NA)MChNePZ?EKa%{;#G_+O?bfN4QlZ zf4uB`1?8pKwW&WRX)RC!XM-4@-2Q{d-0HtnK`$J6DcTSacWVZ_Exp5t_V~h+-796> z!GAz-c~A^9Lbu2mE-oIryBP*z6J~p0quonO%S*|*6b}rOaeL4{2w!HHKx`?_*K7-R zz>}*W;H}a8`==JSX#sX#Q$al(37_!r&-e_&gYCzmPA%NzxN^GSj{DbBPe4$ydQF$( zPLGfeJ^T99)Hnw74Lk>s4-N=L7kZ_psk|u&rN*?+N_1g5fcXo)eM;1jGci%ktly*G z;mxVXGxmPls#we!gc=@NqqF}b!;KcvGbSVlC$Oz1^s2wVut?{8ytdM6)E|^es*!;>~KYKRfn)%VE(~@@I|>9uN@wkATH#WQsL0v zy9E_BJv66e#|Q~gBm(%OwuVyF{XXamhF}TI3Hi+P#v5Nm>rDUTyF2E?MxBTk7PB#@ zuB#5^W9fOe(1|yMh^r%tM?uq5W0zlY5GL0E!>GF5Q+SBOjf=KZb-NVM(ouzYv>cC$ zKTp+>=X$#*<_>sGJJ2Et79D7wnTgCl7P%|7_6EefXI~z|BL2o-C&0mh?TeY!=#3*1DUwOGtv4J*(Owqc zmhHb=D=~@H#!02w_#QTKsD!Eud}hxDOpONH&L%FD zL#ubNe8RH`8*-3kL5i%$+DRlamp+i3m)l9ciO2xn1vRcMWxvDZMC13Tzx8I%t~N#@ zYSD&_z_L48o*d4ybaQq8gNq%-?bRd@aF$l~tN<=Fn|ST{m!9EoYnZ`r{S-Caqm9YW zn@Nae=C&vU*SLJ2Pv<2P5>Re!=N8SaFl?;os!Ma`mDlsGxGzPj zKU-OJLm4dmY&Fmh)#kc%+H?t;yvu>W8zJ=VsVCj~02bwCYl;omIb6zu3dGM2uV(s$ zloWa>M7%%|mEs`w`=_hTnzUMhnb4q167e1~?BkokW!l)6V6bk@!5X1=m^vZ^=b}H* z)A=zgu6KPs0U`l@jn-@6d~O8g_4gqgt(Xhp(C^5H#TRQ7x7m+M9&bnFtiIs7Njkc^ z^js2-VEIxDyNL$`C{X0pZkz0#f3e@|O=L)!n3T|juR9tWWr37X5XR14NV5S`g5R8n z6n))6JAg|LMh=hLc^27uB+g(iUj#~xSnp6xLk<3LaC{Wd>aM=;PL_PR&K^+4OvdqS zZ;qS*bi zNduqF0nCc8+E=bMys1@4EHaug6C0uzr$isKB{3k;wl-~0az8eEdq3N&+?n}7Ma$VM6BN!+ zsn1!~sxvU$JR1-I=I!WMkToF4Zofl#y4Bg9z_~6hY?{{4C=|8jY1*moPx=8+j+aQ0_t|y2kN{?DmR(W>sh~NHC(8Mrq2nDfC%+4#nVCH zQ)F+AKGhuCc|oh)RV;DIb^3xTo7-e0AP9<-rh!M&A}E~>HY6vHhv0dD|B zU5`TuI-`|ZC2pjdaau~sIG`6>4Bq=}A^mI@3Ca^zf%o>0Y`P_ zC`j!HkbkI`l_~0a*b!<3IgUundar+W0KpCVCoFX3|;>B}0*#?`f=t%8_wl?pz6&^^n z+D`q$wCAC@1yX6hfu$c1(7%aR)#;V4qM$4FCW*8P^g^727u& zErMu=txnR&Wm{le5SE{C`7%ftI&K>A3HZGmyXs?fjeUK)cwZOhZ;p&uokb-~-X2!t z8fp+mF2Ha&Y!wp<3z=NboRcue>eC%?&+5lO1*Vz^sTSY~5 zJQ6}72xi0+QFHDBa(ZM|OC1y?$H1Hdp@xRW&5DyTsxW7`l$7K5 zXYMjcaF8^(sbXVxX|As}xt!rP4vP6UayrMf#da1KhlO6=d^`fw_04XO`8!M8V-?Vp zZOkbeC>kSXR*5YkD4^0;q{1G+) zp?p77E;~;sDGu*$)lYDiSEZylp`oD}3-;HzsBoeopYRiiX9DI`(eXPjm)36sw_WQ| z8{l07xtyw1K+PmNKklGUet7)szDU{am*{)36$*<5!281kF^p2pW<&%j3h`0|A zA7No))89A-P!4!BRjn!iz)K{;MFaL#A{n3F1*esjGUl&CL|*%EHywGsl~fw z6fR>r&{a(>0*M%;ldT9!aT-^4TK9AU(tgJABRG-bJ&y+gUAup!V5&z7&NY#Yr4r)? zugoCd$fFfgpQDnOW81qlMZjo?7D%h95CgTeDNy`Q0;Y^)nttx0xV(qFa8HBWC-sPo zqCNIFcm+1=O=Sq=3ORxGQ)OUGrV}Q>qzC?pr{Igv#R`oxl3Sm!Qrk~Ci9jE__a__f zp9;AM7x4*#b3j~K(KqXh{B(|MBnS>Jd~Us%mvp1Po*8aIt<0*%UeQ!*wb`{#$WX8S zzWV(i9Kzg!3rKb8r-Lb|KEmi#9C&$L~2!Jpvt5@)CmM-fyn zAxeJoqml?Txz+_pgaM#-<2%w<@+En5zczm1<{FMHqyS{&!Ek6V}awfbAu0`1+Y>gf$u(*s$b!OInzJ@rwnh$ z&hNbtU%>aHWy9r|Zw}&;oNKP<>+4y-Y6}d}TZ-pW;Omy-b28=tg}zQk9QS44PT7H@2q_Ifnz^PTif((y}A3~05{Z46!Fi%0(?-a5&uIYCzwG;E>Kt+ z21p%nTWW{YT?0ls{;_O&p5vWmGue($J>K;D%~-W`^%`m8=+h?JwJ8iaXB_CMs)EnS z2Tn-r@VN~Bn3d~i&2r&=JJEaaOkm(41n`}T-GFau%(!*GgN>Y7>ltUxDXSRwhqCo} z#<`xjoc}79U05vuE-~5;Or5Sq4+~B%H0ue(O(APEIb%V7gHMQi?ta|X+iq$O3|Z*| zuKoYGo?6>otT?pvrHjp+>N`SK6y5=m(o;JGK_JzMa1@`G0xnb&=dh_9EC_tb%fuKirKIFQdR@-6m4kt`Nk^)EG{rvWw_OZKzp?f3SKCXG* z^|{~jn&vy^n&txjxQ}ZY@VT8a6W!SkAWUI-8aXbOflp#(ap`%?VZqkBWVM763rCU< zQsWX-;-6Vrqdh5fY!m($1X)R3B_o(pBrtSUfRJ!Dt6wK(Kz0X-8GnYofGYe58&pmM zH6sQOHdPq}gnRYtcG&K%inlSSpxTz(H`mLyk$J?fJG*fDMGPB2`IyV?p^Q%VCRMZA zO(~Wem{-Ue@Th8yMfKjCS{EvGBtlYypq>lh#Luq{Y`VN(ADT}!N<_tX zd{QLxxc2KL2-K2O;R2s*m{mb}=+cX!hnz%67_WNCz<6-+NWrD|E^dGhwzb#JPRqQ8 z@A7F%h&Th_AXO0PNQaRM0Q>^kv0U*I6CP8IBa`u+bt_ZDd=g`v@z<9eH&a|Px$QUZ z8I3K00sPjJ9w3ifMezg0Q^#_hk7!O!-P?sWn^)pB!j>*c_XjSeph!|M>j2zsjz*zl zfvRMAOb0lgeW}Ogf#sUUE``U8BO*{aih*R=)FYHYL`)^iGcljoLnb|Llj*)-l@512 zbC7IQ1{ZcBzU+GEG{-v^dSy7}W3ItileZqkzVL{ayH$84@-hSpW6)DWdC@Awdlo#; zBBI+o)>J4Psd*sBO(H2Mi2s)%ok>of9Pb68wm+QdPGOz_rGY>JJ{R!m^zsKeLPp4ZpstPr4TJmNpeg6827xc< z(+O`$%E8eQq@QqT*Rc1`HNPl&@cWpC7+8UaNR`rp-5(+SDA=$8MsdpY0qifp*D$3G z494vbTYFQzsk3ca5j{a;W6lqSOauAyzs~t8ofX5)HLDaPUq_;Xc{RIJA}o)K+OKV1q_@_K`*6Oeh6%U`Vnq^=IB{F9 zM*cxv(#9wmJ@TSd89{DyMa_iB?NZ+qxk+L8o}%RP-XimHH{d$7#!Y!!28Eb0snt5% zl|?s8S~X{YwJR8rzvOXDARwQU3|kZ;W1#!#>B4*`CvYWvnFzS@j5r+CN{dlJ&+XS; z5Wv^1^AD}ki76Bisr+Am-!$zif*NxHhJ<2HJx=;_PXxGdqs#g7xZ31fveWXXKe=n| zgC6$7o@~I1n%|f45?U2MAnwU zUzqLL$M*8(Tj>(5ky~kd`|pMX}L*`8INX19siLh@i)vD{vW0!J9lNmVCvpKhhlW1H|XQM}3*C zo;b#2Ddap=uq?YN-lVp-d=7&Uw*$BaCr7q)Yq=n0c@_P?NPSkOpl|Cm`PqWFoJ_yJ< zg2(8*!Tr>pSNs>T3uto=@I~3Ph@man8E_S2W0pR-z*&$a@Ht?@;&uNcoB31@11a9l z!uQXsFgu#Ydp&c+oMk_doGgO?V})%tRW~E=$*)V~Ep31EH|G$eh(s2%(^t)ROM6;N zHsu9jiSvX#IfAMpr^@vN#LZixrLZ{yCFz=belxUUtrG;zkdG*l-~~ z#w-@^*zB+bsDLvHEThU_HcUTYEOi@=P*zq<^t+8EiSd}bx_qfYZn5qO0dv**cIpV4 z%v>1i+>kc)=C{OHcNdDMv-`*{lJQ)8E1h8Z@%Xtgec$2DtD$@E?6 z8XjmA;1YlvA><}R=C{@>BJv!R61R236wp|FCi{oeRn?L>CIdl*S%-U1c8yUR_?g5_ z&FA6iSphg12nV>ISFACuH(J!p!IC(SB~j6+lz11}m`nFss~aY9k=pkG=P?L%oU z`>Uk-{vl>mLS8tMb&5gRoI#~zLqS2=TkIN(YmVDNN;BM;A-I4}@6kniPp+wf;xG14 zFLrd&um2oL`)Qoh7_P!?tToTyZm{oZLV6LTaUG{yl-o)BPSB-NU)24kXs%ALULD9C zlg=^8yU+vI0?EdYGS!Q1IY$VnfN!hTc&7Les?PJ_S%qU+0^(#dp})byy!kdZXRfc+ z@0ThTD^=e*GM4qDo@5^p*P{}cv%eRF!%Mm9()PzzTg%=IC@-5W=0W7o8s>hmJ3I++ zYj+~CC1FHmIq!2CRCy3jxDV7J$+GddUd_0Y%e6V5f%85WPA|;Gi4gZvQe|Lj{j-E> zSY$3HSZ@*ySGLr$i+Z{Q2CqKAO4W7@x``Ff!=Q0Uv!mM%us4oQAAez+>Y9ySw?^4GB~;uc?k- z30dN4K%l@T`kxia6HbupYJD-LyMyB392ur7tH4S#dzGG@Wb_HRwt0`6u(0qA71XGE z^_m(@->m3j1cE#Q>Yh@wuE=Guv+^vy_sI@OzRfh4uH{gnlVOkFWbEr3k#Z2qa*15# zVvZMNVq9rytSideQOt0bC|}SbR{u!k$cW@X9YLaz{@B$OzWs%2LQfB&Qpay zVu1^_dUXM}o5lP0#V3qRfMOB!Ccg<6`896fKt&gm(nsA zXj=Rs(;tdQD-}0{#Uj%nX--MZ{KFbb+HNBC8FPR94@SkdwT|@ue{8G-p#xbV38VBL z?*x}KyO`MENs+`miyS3x8Zg`UBRh!E{fOUNubf{8)HENB8`_-iu*# z1=@DC)+v!rx{O}CX}MTYnScuc&3tomLJ3?J#VJ;My+cbjn2(q2?n#+E&vO9p`B24j zhbXhK;rM{h4|~r4#!!KBRFiFM+@ufHM(pKAe!I%QX(`s|;ufZW8E1f${&P@--yRy}@3v%!W zXHvG}`lbsfE2$dR8$Ale@pSxy76*&Vl6pDe{Nc0Qbp`dv@Cx;-iyCT^dR8HWSb8EqcDWmHJ4R)_#s0&kcW;+HZrzeM$K4?WYgP*RmeFh~ago-Q{Etb|? zE4W;-!&EpWR6JtL8P}TdxnT)QgEReJHM^mBAEuy1>>)Zhz#;6-Vjz5;UutMTjA-?K z$BVvtV9*=`a0vUykPy|?HRyGWv9bjeap}|>Mok0Ed?_D3Eru5KR46>$APWwo2(^L`%w8BIt)hd6KBGY=(vmB)=ra|~0OG5Yv zfA*{ol?zKgqxtF5k~KN|Bb*N{MG7irzbBO^jC;Jfc#j$DW7V=UY2RZecMEo=CFq06 zi$v2HI0++NIA}8dq@yTyD0y?IH;D*B{A{K>k+2wLwJiA!?>}&D5pKWjocm?58!jms z8LN2#3|UTG)~4JLX%@NGYX5r2ozH*jp5i03(+B8n|Q|M+4AY^MS`x*5`_KiHeMGu zQB>qeh!r~CxkmtIIXvUIHcml{K&EY$`+c4g>1hyAeSG8Ki`}-e)4-{;hV$5ub zH4e9s1>{KIWF75NE`W60#ZmZENw0}y5{s1$tGnURYbK1dP98jwpElK*uvTZ(es#Eh zS?xWy6L+rp`JFc1<8(oo@u;E5Px9UK^yzhDlL4% zaTNm|+vHIuMaDa=lxymhCz4!ad@nuby1}|(8%eKl8UR(8&2u0;mWqbnk@x^)8p94X=CSvn0i)@k?p{|Oe{CWmXwME7JQPbJgxPIj{sj|Z`&Q1AH zNFJza<_+v6Q%bX<{3J*S{e6pd%>!B^Tbl72v(_x->@z)b4L(62reAO)x`M((nTstl zz|_3{YNKy#Xpm&GQ+)Y{wh{kCTXwY9XVYiHxX`Rd;wfR$^Oj-$3$MC)EcP-Z3v`kB zlr&4KS21?}!26DhN|#s?YIgMZO2qthADkkjX{1veYxMTOX`AQWT0XzGFU`*TKsG1$ z+}^M2+tPu z;I64&lZZ5QX)+A5o{zV<-801{kG;6)ydoTasBs@%t_-`|#8Anjte$(~oh0Q1u{@)cNEep9U60&)29)?v-0L54%hx8%#M`a<}C8lq)14@BHtqkFf061!NjJ(>QFD-wUL)i~I$VA8zR)e|z+qDxE z^uM_8;d9_R$J?7;xKxIpQ}Q4pZ((sicf*)&8&9`wY2W^)bBa^SK|#P8s`H9QDE|B6 zV7k5-XsV`z`Hfmj8K;G+Xo?V>JF9YfYLT>T$Mw!Kv0D>BjDEMHyAFXF1RLLHxZ@{B ze;zId5($b9@dbcMTeCG7n4dTHfF???RC~o?yJAS^WGP&@ytoLQIQjfl{3_5p?YUgw z|G)pR4Flb2e&9)f((6d8=%SeSPXBai6Sn(tXJxX(88T$J!x1;!W}wsqJU&3X^aq65 zou1i^L4TCbJ+Ynfp8n3mwu2HJ$5E3Hh#$<`%PRNLES5AGl#^_+**Q`-$5&zd*D(J!Z!Sum=o8{J*Avxff zZNKMx49v3~LgVzmc4{g0l?dG^1;4EEeauqwWV0%nu%;3aak{jWTcohhYyP*DD|(nX zfkcHwy-XZ)tOU*ir556OO$={0wzQDw|2t2fAiHmNYF0L+ldU~C(?{sT68wKx{=ajy zL{jF>aVn59gDN6Pm}kXGXhoVxw?<-*2_HhjC=M__b9e6=AZ+chSESfcLP1-aB=4i@ zf0F{b1K}I)oXC)#L7Oq%W9C=K7@??ozD6(ov$H+yAGfVZZwXRHvpjbC#j?oKy^24= zBsFWkKD^>cRp#nLsPO1{pijF5M#i+&)Y??^Gs4jU0gY`fF`wk5>FR25PntS7zO z$g55^lUEdku)nd(N-@s*u$J2668c3Gn^p2djxr=Gi=Ts1Y7z5$Lzvm)?p?V=77bUzZMHssyZCc|J0>NkrMTVb zbDhdy@w>YC2akpH92GRQ1=-ftmgj|5H5Uib>P?l1y1F_v0)kS;jlXt=)7@@51UkeO zIgFwV*>?%uAPikcrsh!JAveqou0jS|*SBvqtL=uW@f_{BvK*}e?a9a8iJ<6!Z?~rR?@G-?9TwM2 zqR{x+QnBiiTY36LA`$7g)VadCIb=(7rWmIQjfH(|2xR-?NoqmKfAtb{%Zh^_#OOG|G|OlTgavgGvKY+(5%Z0ld5Z|30zg``rSLFKr7S%fctwsh@PpZnq-xPOtU_e5Zq4*LR2G-^rt~hj)LtU}<3v zSb^6eXCAe4^20r}uu=wjG?61VOTZ5V?gbm+sB<#)a&)BU{&1Y4bRTdiy@u7PHP=1i z7ecj(RpKUL85D3F4~xMt8lFZHp?ZrVlm>o(h&{=v)Y8UX7zLUD-2GO)`ZpBgHeToM zR&O8o<(J<~iZMAv4`+--nAK^QZ*Ky|+%8+x3(xNU>)m1UfYWjDuV`xJE^iMhDXDmY z2v@cNr&C)8&qrjdJMF(`*>@q_Q$a zvEjdIxu`C5#8_DBOa|LhGTT+`OMH=_kPx()FMh2QaRJ9+58k}o^17ZDfk0G@l+l(E zxIb5t(gr=ZF;aCkz2okO#pzseWyj}|sE6;uZ>c|#OV9yuTYm3~798$dej2QKYmyyjt$(TYG>vv&sg|6Bl<0UjE0oA}Wd0A((LP(g1R zc*eqksKfSh<8+sQsEHen;nWG)ln2jdaBigAv-y7 znn4@I9+YLV!O4T(H{lZ9wKBlkjoshMysbk0{O&012X!Mj1u)*F!(N>|#+r$*3p-9%yB zv7Cu|2QMRJrFw1YX@LAw5|-ZxuW_@%%v9%?8iH1eSAEpW=ScM!5uca(@r*4Q28~+U z)06w65KFnuIgj_vTp(S*FC~daT}_!myW>ktTPecL$tlF>A_AAk6=mZoLbY6ZR?-Zk zhiM7bJR^US50M|&T;0LOuV|VJiR&gU=1GH@I|B!1FQ+mxvM5U-dBmI%cO|Mb(kr65 zG)!AsCQ&AJE-Ji#>h=Zy-Voo|{d9ot4mGZm_^bG10PlsoG>aq7@x`=NEIx-bn-dK& zQL!G?(KT85Yfz15muF%f`8Og*bAkA0p?o+UiB3f74Z6a#p(DscoA+x3v-&;WQfnD^ z%)K<}oL*}dF%gl;Ba=gkwe4Ia2i|F6xS|9zX<%l1=F4kx5)jBJh*3{AIGz+KiGKDc z$HHH)lSDQvb{m+bq}oyaE&b*pb+X%ew=f?>nx{&oQGY-|g{Jsxs#JHT{&a#KCK2Z#lD(^NEq zCkzP!*i#`1Zmf^+#M^|71k!5TE9JRm31-G}Bnr0P)%B&UB$xv_!Vn8|ZhRVx8;X}V z536;gQsEietZdj(4f9xsI_IimvG#XKrso3trBcgoMspj9t+zxP;8WkdI_~i(U0D-Z zD&7`Yb`M7(Gain))OQ@6D{;|kz3y5X8@LaOr*^X6p&ske4~2%^ti)Qb-jsxzd%A4L zg$X1z?KXS^8i3A|6>bHC4EA&C=@}sqt|Z@`?06}?uk)iM^JD3hRa@QUEBL3q8mqY0`c|r>e zBcQ)fB`37dW{HQyA1Fht`o_PzeOXLi>Pq*?7|S|YoYQaEyMFzp*C zS2qefDy0n_3nA=;I!9rmB!ntLHR|Lap4sl4TKIRm>aYP+k=5;Jg$Q)Zl#B0dT6IT)y22%H`` zMK>;+O$3NqL@RddiZI4^)Vr*shet#tp-*ZNYZjECI@~7~6`?wNim;h#b{z+T^b2uY zE^wZD`>D(R=;AJ$&Q*xl^Ly8rV<=^@C^2f(TCSceb<;D6GSb5(ksv7;T(88uYb!ya zNk0S1_=vv9WJY5Jd?~KwvIwKH?CCFPa}~15*sK=O859^y3aHYIg+)Y6&KgJSeK)14 zVC4chk9vDPE|<}iaTE44?d0NsVVUv5SYGn2Eq>}<3bO1(Ab&G&p6*=49zokNe{>irfwgA`@zw22!x=O|Kt~wZq6v zD%giVh9fb6h_emzWu(R363%v|$6>Pi@aWs4R(1nT3)^E>xD|Waw~P#di7dO zK>>-$-hdmBzz-{?$2{w^JKlYR7o}61=S~QCK3kL+r&cMUEjsU;siKz}s4FZx)_#3j zUp-6Y3J$MyogBNHTyEI?Nl%;W`vm=_Z3f&_Ip?WWf&W#oMD|m1?;azu?O#RSSAP-n zjlGl8ysKHK+tnJ;G(V3r3_3lfFB$J_)9G3&9JSUq*MD=)V^%S{1OhB3IVy?1=X1e5 zLPImXX4}72zi*)+`<(W!^`vvKUW#NrzvYreEtK;P&Jdu_Zu0r=u6PJW_hX;M_^0%B zrQ%c*4HE2woSe%BhH_M;W>t0i`-GFz>$9FN*Nhky;)EI16#EK_J_i5_QbBL1U_gH);^FP^o4%u?5SJO_DyeXv zvy!+{xg#3%&!2I1M!71d3}ECLg%XHkRT{9xMq%>YuY-fRzsgs}$7R}FeSVOa-7%Zr z2to^B)0v1!kM@GK?+AL!i$~*v%?DFltY9b(X_zD+^1sBr5mwLA!xKg~1W8G7%%nb+ z9>@cbCTZ~TbH)a;4W(FLbVKsdP{B{-$j!L^ zRcl};-(gUEGLh93+B-fkHEHcfZFD*J#9}!@^RS#@QLai0YofE5`kF6tU@8C8!5e{= zI9=*PJIMR_ucls}H8l$F9+bQoX)+bXMTyZ$c-WQBq%XLc6cd0>F+VUMnr#mX7L<%{ zGM<#!bh#D~(0)cLOV<~;k?&u8e1M=+GoE||YbMDS++?$Mx&w`f2nMDX#iMTW{pDlN z&LuTpGRX$7j;ickFdqT;@ioO4JQ0RaWwYKq;xD8i9i72ec-QB}nCL(2 z?g2GYq1HWqwb{M^2izTxZ!(cxdwyuWObLLO2ZeBv3IW@-biA+UCt_3@zI=l$LKLk& zgIFvmJrWR?mh17Ru>z8S%3uj=QJbiuLkM3qD@6=lXS)B+mplIRcGlRAyf{cNj< zy>1ZwioraSbk1L?xi%fEVvo{yo{=|rt7Q4l99+Vy9FU*A zvdS5X(#N8z9q!N?Z5M6^0`8}W7)a`NX)=%q?$THrP2n zSX8LvAI|1)oiQ?Fe~a)D&Ibb94%%5YZHP5WxLDVs4eX8!Bytkx^7lU71DjDW+K4vjSHUaZ>C6fg+j z(_^>QEuD--)YOdr%auNFZ6s*}Ts8g;Cp;y8M+%WCmOe5Y2vcMBd7^EC>*&Bt0?1|d z^BS#c`aF0-uZMQX>vs7(|F(M$*-wQWUh7a$0R8|%+Xgk#=r4_Vu|)JNLGgQM@5b(q zWPny-gM@WMZ3GmVBc$P zw49T-Ual*vhHacvDX)tJits}JucS;j_O9*P5RRca;tdUqdO#iuu23h z{Yc4|^A8_r)@oQ*F1EG<+TX>1s!tNuCOGSAl32?tmUp-sx=}6QEQ8nL)N5h$gf%7@ z%zm}uQ?2Shb-Rc*q_{4OY!^S|h%c8Hvv_gYbasa&f$L$h@vom{8Orn9Z7xt>@qL=oo?hwufi{Wx3i+JDJZJ+Cp+dI8hsOy5X{il`^vdOceM;2;UJmQ$NA!(0i!m# zDY?!$0_wJ!nwqdvzE4>Jxdzmd2)fLbvumlOZNZamt8A>Z#p=T6e`*!^@DbWWGXiwI zq)X6GH>_@P%zgPH;k&kECQSXHQX3b)Uw=+72zzh1P9}DXZ5d+Rh9$#~Pz0hdq|;5o z2z=bIkQY%aw@KTHA}*;z#@UUr>OIu}UlwFU*s#cm+sfIPI4<A|-s4)G%l9SEI*2tLnG#;n#e~Oh%~*2Vjj>!K#ARV;M&b`j{D#v6$9-@DV1J62+6a(QpeIodvL27r~&m11vwKmr?zDD(GvY66gHM1g*=%cj{QH`1Zy8x*W~UbX96Q1EHtat&PYSX^hzON9`4J2 z;E55;$Bq@Z2$>NV+$^yZRF4p@)$q5RRyb29t zfx;xDn(VRdrzb!R@#y~p&p!(qddK&^D=}3|jZsay*H>O@3lmuY+=GML$u6Rsk(|)p zd59b5k(F5CE2!n{{X=+}`u476&A-w{`c5n@$$rx&)Cv~eUAR@RAY&RnOHMRa_r)#< zxBorO#g86-;;9uI+{DWB?uWY>5$-cFb*GoyDnwjwl<4<3q)l&my@CFOyIPKIs! z#}tkF;Gr70SE)=z%5&eT6(^+>VabtShU{ujr_nbBmO1&CFpkegaX}WTXbt1!-&)@x zW;ZMjn|08q&jILczF)vZ8@N91UWgtMPvY0B^DvRE`VyGG$dmR23+a-SV8Nge6B&Adu4E!I%%Qx!UNRMd+zdXz zI=|{)jkJMfXaf=yAV(n7X4hLrv#$b%jO$axU8KrZHPHNHnc>8r98;e}f!6b?`(s^KmUjPa2D&Tc+EiN z&{&%M>U^=Zji`J$;`0DzVj24v1l)0XWQ0}QRiMv|W&+QDAN19%noDtp&SW;q6-3Cc zTPWH=T%f-ix7%3^(iAzskeU0%jFCjz(6rNZpUijjZ`~Y*%wu*10FTH;^8|^7LFthV zOL;veUB#^Tw@~|NvE2Fl;K&ACB4Hp3CMM0!WM)m>w7BO!o7d6NN|F3!yEF8^$4M;~ z)1$McS~LBWB#JoW+{EX@HH$DaCJ`veB(;Ae<{aO0=6&r6<+D~Yj;5k9Q|79OsTEM* z$qUEWnvS9wvxI{0EvVZh11{hI35la^KXE)(lXk11UA`cD{l8K+cO3O!Sx{!X0hv{S z&wm?D2eolKurYe0)Pky8T@&&h(p(dU9b?vAgARD&Vschq?^Q_JyUk-@jr6lxk7sfb z<@PEwIiY0LCH*kvXBGjc?%spR55AkW(Mn zuq{Rd+L3%-XvhQzwVWo=jz86VcMpDi?+!@SUQVlYw1eB53C-WW$w z&YN=-zG=*aieawbJ`^p0r)r=#&#s@>eV!0BIM*M=Mh%t+^-vA~02I@7)#(B!7f^|! z!P#>2@D^M}05(rZs61g1%8YsOM2rzcVT*t6DzL+twpOZ(|FQS7_fKRv;9;?_5r)BC zt#x3|Nj5xu83ZWZGu4es4{dT{C%kqTCZe{sC5Oj;;IKBWHS~aSOC-JM6d@0W8 zj}I;#LQv(rYA4*IF~+2GJXsBR5|#0=ca2cKUmf{IEn!}@YuSkTL|rVc^O+g zi=_(4qi|`B7=;Ecg!)svrtCI|-(ou_t+vY+!THN{HC-T`=eeUqi{fKciL)^1cW{e5 zx9rHOVK7qyhak>@91ji0_eoXH3-hh^{jstoNU0i1Wvojx(-b)O%k%(LFN8c$&eObYvsr{rJ|ted#W8n|1Kj{er6PvS;lMZ^W}fGK!*n=y z9qKp%rs=v5c3g^lFbN^ex{^?iW!s-VLEfw<+$rS)*eWb-3C$1g=1wg?{K}%XYFj_<8i&LX3RL|<>O^+loD>u&=;}e zG2{+u`au3s=ZcQEa@*dW$Mc3380W-Ijh(OD&jD>zUR*2`jPX|a+gg%?S!ci34g!mL z^)v9Q{$*w8?&zWB5YOH0fk93EY{`L|u5N8*8Xp&Vza5m*G=is}BHAkjbh`XR0HZ<) z^teA&Y)$tUb;DsRhg~9FFE16JlN%sjfc}R_RWx*%c0z)Uw3zQpYA`jxGi@E!3Ub#N z4^_Q4au9+OUg+a6>9if^cD7H?nx*8klW1{;CgD)-SMVM+SM{<^;|bKDayShtJPZ}d zT*T|we)LT3LpyJg&_KB-%0ByLW@9eU^lJsbD5(c5HhNaKO??k@6^&sp_F0ZRV-4qQ zf(mZCIU5#o*frkdNdI~L*=EjmCk?e=8G&BLP)_c6vTLvR!E8CJNK}Q@~v+tzve?agP?z{csR3@Y{*(7fM+XZ59uz z2fd-$vHMzFM2z{=dT-;B-2HU_`P<*jx7odOSW+$NYu6j@4$jlN$9s1lo(MyWMnneF zuSNxrUn1&S1u^&Gggq-_9GgjI%Q04-Byvxe5OERE*7J=`uIKApX`DhjbRCv32<4`% zaK%^pT?9gU>(<`Ou{S{Fs*NvK6ZZm9dX}s30616qT>0x&2cbzvcx*k`5uu6>$v!BdnApPzOij-}C|lcspaOw!-I5ub zm4AhP0x&vMSv172iWv}5A0KdP>?&* zC>J`8fOLCJke3_uBvuRi-tdrS<@$>>MV2eIO6qojgT#uY{$;v%)y({@)Dp4``ZBj0U}is4W`7ea%8~P-wxkri z6O9Q#rJ3Jq{RsPdQW*RP$*VR;T#wBQkZP37*xXfouv`=zAZ0)sryYn;hhcQ9Lb&Bl z#1p6cA%-WV(`@J69yoz%Ag@AUj2)6K|Na3Nk{VV~#Y@^6BfnUVIbFfBYTDiOv;Cz% znuIZ5W6-$Xe7;3Nz-qOYi8!0sjm@sj`N&O-s?hXAlOs@GwoqQ2;;3lKk}?eY>*@S? zzS)h(GO6p=-)F@IDd6?7G<~0DpOjJHIYBEKcpz(g)Qyyn$BNIg#KAAMM%A zelcp_)R+#jXmy;z4kTHx>CH82CZ?d!UJzOH@me-fo(W(}P^HWU_VmasBPf{-tk$N) z5QOqJi%8B;xnT02niFtkB%{bc&F(H>zu4)mjm>I%s{0mugkoeLX)kbMF#gZ^E9Z(IXJ#xDxysk5`pCc-Q^ zw;DupK@Q$kC`7Ih`o~IuX8!TR)^C|}_E_ZT+~2(OtlmLB%?b3nYgkxVq*Iwq3Epn! zGQ6^IxpH5c*rd12jv}LZ#-5*;h16JjLUx9axishj!@3-{tCE;AjBFGj%SnVIV`(#^ zFRNI-;-y7WP=JX~l^jAoaMTmLrgw{d%^H4Lc_(I%ZH|oGv6q@6vOK~?&@GJd!RMXO zH5ys`BVe;k-{A&cQf~C5nBQx)5jC?Vu#qF*AIa5$VJG0bDrJbW9PyH;FK}D^lQfgV zpsA5%bHt_9;m>lBbM;So1W%xVhTW@CQ~xmFLNkOuvs9$! z(T{G?z0+(XPF9EgzJ%3{{-;UxReeaWRUHad%)#s*P!7FJvLX3?A)9t-88*x$9j*fjF2a%@o0I9|6h5NO_On~7Ix7s; zey2|6-gM(mq(m-Dl43n+U?6^U&SWkxHi>?oNQBqKAaX2JIy8`FN^qgXj~F z=D~^LIw}9ko5D!z{b)pJ!%i#Ju<|N92E*Bynq2^{Oe*-iDU6%JYPH?g8>!mwlAvOX zii)gv_NPO1BSgI&gIVUOS!q@MKOcD(8-El?QXB8wliEo%<5Pe&Z;h!qt>;uCS&6bScj#PSe?qEI5kVaaj* zwXbJ!^owBV93+f67GnS!=3`Lv9R z2=4mZ`!`f93Qf2z>@&@r;L6UH1z#oq6@ocpXqgb?*c^69cP{k zfoOC*_Q*39=qSHCvNk~|(tKjGa}dw^G{nXZuwA4X>bk+esbgosX|)$#<{dwf6>yjn z10jh4=20Yj_0jyln>{j;{D<)_6;4J^$?K}$*N$Ud*xcgxzsDZ999wf6y|{m-GpV`o zm!ioIk1^X1OH!uKPmjr^+e6Z(zA73Y3X(e#AtLGSvdI5xoWb$2Ss06K98H2x56+eC z9Zb5$@j_RR%$;Fju4e%;QDGdGLN!p8Tk@pm<0v)&m;^-(&^f7=7?-#Na%#?6j>J?2Bhf;xdaggH05z1+GJ2Qhe&YQ(p>E%*Sy$ zD#r+9QtK?&i1T2IY95QSt)-d8<8lb)oV*v&*BP`rc4yGHy7f-uXPP3aSIy@#I&93ZeAviVFH+~N1@af6`U#}cW7C+f?a+?;{dbS|Y-RfwNERl6W<88Ds!SUs zw>9)Ee=4@_QbYRxB#7z5^0t?ewKL+uo4?%PP`Q+TP9C98xbAv}d9n-8CDXf#9U$4k zvK~k1IXO8}2djw>K!3V!v!3Z$E6RzCc|6F;6XahhAgI|vWvin$Qri6nff((L1vaOI zJUZ!Y7PRBjJu}?i->Yo!kvOPV>e2)2amrfTeNL5Z7qOx+pH&FOfa-xsjUiXRA0lX5 zTYRiRmMd4;&_upEcAKohP9}AkR&3u$qo(-a6{w@{>$}9dRM`w1He|Z*gy3h)``XT9}tBv!_&ZN%Uew)V6$qw_EdS$q|AAKnq83!Yb z^WM%o*QjvJ(R}{Fq_0ciKj!S+&UWdBzUT)wSGGSdV+L-l7(q06%IZF5uUay}&?egj zD}FbmgjL3$P6B2G7njLMPQ9fGj}uD-+M|m6nB;Z3aN_d2>mSjP-#qJU|Bm#eLAb%F zm=?6;hHbd{Ph~%77%6^8-=Bp*if|mX;}?2=o%x-Ab%}wvMDBE{(NwSl+1Omg-?zA% zy%%>&uCZ6G*`A28;DMwwqj>&!AVS0#9}|>@*i&C-2=;2vSH-`F06{0KE~w5t0sC>5)g1&8`&&VP?0u)wQ+(WmZ;=hoeVebZ@2Hl@ z@1E1^s`WGV8JA3TuKq!6Aj3`KC=Rs(r}<5OI50Ix z_h3Ub#^+bL5~(+XA6jeq-CW1NOgve~>3)UG18(NVNO9I;ot)~>KYF|-^Wc2DT~ z4YQ6E4v?O1V%y#v6o*6T&H%o>_+t7TE;_PZ=|OOR9NB)wrPU>B#vdNSFBYD->9}*3 zyc>K@7l>WWzPX?O4IQCse5INl*Lg-d{`%#cf?q$s!?isbD>K*jc?pE)oay3GpM)98 zd=ed71h#u{HR$W7(IBUHYZG2tC1VIVOl))(DN2XM-MPPedc=8>S~e?@wkqu}aK+}J zT6Ijh<*VLGQH$3`(ncatB}f(c@7jaXePd?49LI|rC!ArS!VYP6D|HB5-VeUrb+&Ko zeTc`#Z;}&wf;%C2i%mKJhEHOLEkhG^LnRffwYj$eu)~6*3`|V7^W`Qo7bHq(xR7)s zkz8W-9r+HJP$^>Ov(;06&Yux_LI^%6d1to zmQtx(r-Z?vO;({{m(pa@Joaa(!fb8cZI#{y+1-eT<}(ND7fD!riVWud;K&3jMO7=# zpKx|qyQyJvLEfX#(ptr+707ou*c&$KFMofs8)uZeYlcGcxWxOhI_|ok0@Lf8zamQ0 zW(N%=O^5Sg05Q{h!sPwIXfJVE4KZ5qPET7Q3Q%vaW06_pc?X~yg5-; zkvu$cb8|7=x7g3Y00WPrJt4%n-p1rn=G(zv06u@pHsygmBd|(fpI0Y7ORvLn;KSWL z8QmWWXh*W zxE$>l^f0B*(44vkHRWB8pefOCo#yKJG2yNBuBu%apXrrm1w6@t+>peGa0mOPBk0UI z3YMn!Ex>hTG46$P z{?y!)iEo5#q=kgPwdzxMN8*#!0T>R*yiV_BHOH+(eK|-E?5QX*%egT5OQSa5Y(3vv zPD+v8@8p@YOY^FB28a}IvRM)*%=-1{#7Fb*X|AdqI5c zXiqjH$r=YkQweU?o_4Ue9~f-J#dNEeqh~1`3cIAHW=w|{7`q2qf*#fYGi9<86ih?a za1=={&&d`#;@O5yLp63RZ{9urncdcb$76wevhq(Q$tfyDh_C z9Y~|?=N&0-WlwJ`=qUsn`N5dNN=Gx!?rUPuDKZ^!jqeKXNimyaC(HXq`jf=d5sX%S1EJZB*v>yP{6rl?begEm@bxVi zSk9)B+&!^apNYqUVZ`6zA-w<{p3zW*3!BE)e}GV)(4{yRF!(miyB`OZbBT_RUSjt5 z0cp;shzhD0##TZ8E43EYsIiMrY}2vVg^H`DloGpSB!uOd`FQXP-nrnikCCzO6 zXk26IxuF73nS?vdfdEIQpNfv~t!$Q@sPuTOA;t@EKW8B3~&0k!hk-+PH&4l%&oZl37w1eXMXk9YY(ALOTOM zbLvy_@?Wo^0_Tk5YZkcYb+zP>XR@YoF`r3_OxjM&_1DgWVn~9j%gwMGJplQX_LRbT zgWua9inc^LK%*PK#*5#?p67Lrv(kjz_~Ye73=J1AZonFMd;bWx%(t07ZQ5;4xCO?q z4h|KaIvke>iG^Bpuu7xd7=ahfONNa#5@Kixj$P)%{nX~PnK;IvL1m<6`Gm!rL5)5r1&%wP;Q_~qLVtDZQQC}V+iv7phJ&|xEnLBpGuI6Lyn1L zsi+tqotB12Qy@<1HF_pqY&EDsNimc2F@qAo>q0(odY=6D{oc}L3})uc;zSP@Sm{+S zC#biaLz)d4J}uL~d%xD!hvYt66D_>wXSkfUwY@_)RCB&(pWcsC( z6cLSp{ZNhz7LR^!l%+VZ>t4S=Ffh3A{W(BfdQUYsyThH@&a0a`-j5~ldUlKeqaEqk zzFG9!4#7pX-uG@yl+;NayL(nST4n-6&g|&yaduLF_Re#aePdyRLUFi@lR)A{ueRqP zs!mrqop6NuzVvCF-!@o%XdnX&RN5rwB8e~{tHxrGdw&2X^id$lNvlF#1aE%}8U7(i zeswT?ver42+0fX|Y+~7t+R)4}=qP{Vf<&j~dd@|9thd7>Kop1&Ac9<`P3C+;gkTBa zId6$-x7Y{st11KOfS@lI<>@Rgu!dnGD#fza80`Ch)_px6Z#2_3TU9Y@aG-O8k=I6` zvVZrn6zZHTfIFWNp?Ei7rI7`2u9=9(S!-}U0K*&LZ*o_u*O9m%uqRA?lN-~Z5^Hij z+9`!e5Vuu2BgiLY^%Vr+e3I8^b92I-cheZKgIKQKMKCRipXhbd)FB~--dRs*;<+{P z{SZ{TwQ~v;Yce2fZZn&QU16wG@A=nmV;StN!+h$k+50i$(lU!A+v(Qo_g;jg%}73^W|%cc;( z@(D#KTX(rbXhfq^ow5CXwIS9#J1}CW!+za>4Gk7kz1Z6*%raOnBW zr=w*ZZuYv@AM%^ZsEQ)t4lU8@w(Uyk@!yi)sLlquM8=YO<}LKMi)~|Zcfy^Y)}OAp z3fzdgfex*?V~4KSZa|mYg#Sie#6mNXi1^uO6uQl-W0?avT^jwqWu@J7wPG=dRjd1@ zyIgb;i?*8yz6SRp%SydFNM}T}F%=#nR@PX&+kc4kIuhvC}H5 zB;qCP-k%7TxNWx^F)xj0JN&|{C6k~CIW}r~Xf=2_>^2J@a`^q4yOCftCyWIS9roF+ z<}IS$0dhXJ;fUh-u}`Z`s2ivXyv23{&IN@iwuFA1YCG7ByIQ-BqVM1vz5{Q1%~5}? z*L0&&YX2gN`t?HPX|3gT6?{^!8DcDTauj^7&2)b_%ail_f0?0`0(t0@E?|lK1|k9L z@Qwd$)aB|L{}rcsq{-*o6w|gJE1%Wl-vP}m_DdTb&h@ZIL!Ca+ic@mgv@#RYJI$>5 zt3o7!m3JWe%iKWUG5fv7!MJzAG9PYYyxp10Tr)-^8UJ|hZW=f8y!}af|C;F_ubkl| z`=>M^73fZs@cWt->5Badetyj|yWGLd{|q*JlxVv+2!=!)n{-$#OKqIKV7Yjom<`nc?~ihtV|*b7dFsxamH75q6G!|-hiIU%vhf|0pLvtMX#yz2i{%Sqz-mZ8kk z^^_aTy|AA)f0hlMS`Qm~>kM;wT`t{v|Fn?$=BblpqCY7 z8i^N)S`ANsb~)6N5s$~FLcmc^8j241YmelraXQC2hcBJ37wZ2o=g)ai+fu3B1=`^4 zcDmBT&$Ovhid^m{SwtT7{+04tIL+kf&6iyI)!To1XN?k+ng7grAqZR8IOniL#Nj6gHpV4p zG*SP!R-&$(6SwPy@bcQEN${OdMl8BaB-yjBC4o5RR5K zcEX08HM{i~Wtuv*-EhzDyJoH5o=!`(ZhDP^%46$#6bmMEdw%$0D}jcQZ?(Ipwz*i? z=ycsrow5MkRX>05aLw+z5=l7k=Qk$xBwVX>W3n>PMK`PnR*{9dgw z#k&ix?LU*oZv;Xn_=jquiIi^;x|*h(Cl}I>+O3Db3P4dUg+JMZbTmelDcie2Ar9vj zh2kv%t7R=rb!Y69C?XNHzkN~oTOmYsCb(EG&;8i4new+gF(HlvVLY&fHe_*2 zD(RSR7a_JJ+jhMZ*`$ZN@yYNp8y@c^nZMK^#^m*4jpeP=`LTW0o8eT`81#v2qz8eK z9SRYP6sST=eZ#_y=U9JvTO z9r1qIzd7G@Gct1W@mXXOr|)0Bh*aFrp442Qh^E9yu9qdvoxm^u->1_;i9q6$Bstw1 zv4VUYi@AUK3ve_=R45)lKUO>vjC2O{7xH1hi*ynEEzQ9RlcJYR7fPk)YnrlLv0rJ< z7DP!*HSpKh-dqtbFgp9?_@{vT<0CEgA>-_tYxm-GuY5&J4>`(<41!&7Kz6?@$OhE6 zF83$ru8FbnVUhv`HQ$b(2QQ>$%;G<*)zu1(n!qPcV!fY!17o3{7{OHG)XbkuMVMm0 zKtqA`=XTjI$m=cFWfb$_10h1FI7yf|?5vWtw(CmnOv$ zB_kXQ<8n_8hg{JCeX~S1o7ka~30Vj}?AM9aYdis8lS0FF0s%Ulu&&cbW2U6Mv+S@QtJvAm zt&;a8ZfG;Zu^Hyf37`ph3c38Ts>z!0xVpu6fwgitrCka7;NpEhH>1&6ES-oD8Yv-4 z?Ltz35d%(cEFM2zZiO7*Ax5lECQDRT$M?J(@cCBdVrph*&);7FH(lMV^@FiwflOT? z0?6Qi$GR-0{(E409*Y~`BH-kZ(80}YfcWJf{EQuvOww+gxJIvIJnx#Bw| z%M*+`Z^Y1e26(8zK{T=AE)J`zHP*$-Ry#lR2=|S#->zhAsCWWYelKSJBgPMv!Ui68 zdSu{c^A&J@S-aOhKBhuJ#Me&ESH_0CI;~-}!OofwI$vsF@alVqcKUtz3!C-E3MTXi z%hOO@#|^alU-d|o1#-qW3|+U87V$Jjz?e6+NH{`>^`fY|?M|M<-y4Lm z5H0aSu*sWzQb`(8eLSe(ZP2C_up zp(f-_U5GE|aHc`PcH|l?uGk@Ovesr&cuN`IaR_u~6Ax{aNshmoO5ztkaLEyZXJBx0 z7Ua48TrI=P1D6&9w}!`@6*4waIRBN#bxy;Xy4``OP{M*uKz4nI2Cv8nn=*-1NDrOt z%L$#GbtffVzxR9ZH?vjZ?}d3Qk!30k0a;z)5L+@zLsJ_nIm z`rv%UK@kcaztzwC`7cGzNxT%1eu?bsPfozXb0i4~kLYxvT7!p-Xha@7qUXXzpL6Wh z#ClrqbBv8yTC1@n@kVAZFVK)rB@%`>8deH@QJoP0NEi(R-3UiTl`};*95*tR{Wz7J zN}YpoRew5rjF8QJi)=)UAuD7IuD2`a^^WZoo zsX6|C7l11MzH12pZ0dA1;AM}3_1bEobl3F#c2b_ba>py%YdDX57#E9kkNE8uiHCR$ zUI~Dn`wi)0Cx2#9366Yv6eBEBzZ(md{PnLqfRzLpht0kinb>^C^sCZmpKGvlrL0(3 z$zl=MZ-TGKQtO?Qxu9b54e0F`84*n?X)=nlB1U@j zgtoh*Y}`U)^Kvp~B1bUcMR%nq>N1kwh_3rsJqS~OeXI=qw?tF_(oV&|98n-eiO}H7 ze1nMJKKA+Sp_H^i!?^QPoyfF3PcLtG)k0gmK1~r?S-^?g^#z*THsI3^L;5o5Qm4ir zeJmb^yG#{^%Q)Vfqzao)wA52Rh-m#tFs|$vp;w2ajjmA%S$F_(V0jtiEWfX}yxpJW zorlOJ4|ZB!WURx`$l(=nj*@iKrlzx`xj2e3GMOPfeU?6OGLu6Fs?sW&1guo=@qWgl zue%e0<{z(N(zpB+<hji&X?Acmb&fb2qYsJ8U~q6>Z)2 z2c*kg!T?q|EPHSY0u1~NHS)Rh-!vAxB%A1`*+}t}CH!Q2CSER8>&dKuFoqT4mjB z|8p>eP}4Uc>a6AxiLF!*m-x$0y9F#Ma%B`KQ>d^xglwXd4hLs;1W2>XB>cynPdBM9lllPy z*u-|al5v|SzAprq%bPXH$ZehaQEV+g9|D}?N(BW4{gpb8wKXUP9Uq^pv7pHwiD54? zVZK2U$f8Ewc^Ca8(cLSnG9s*m$c7a@C$ianaSSctm7YR3M?+q~F%N@}nSo(mP7P#3V_i|ROo;CKTkDwJ?vCpn`#jPccbYb!WwKtr!ZjIH5aRY>?j{#R&;y!* zSn0H4qb$N;FVp#k?PY}{Bm_iq!azhOIqDU#h{u|y>-}LeW`+$4nTHn}AfidS2!(=y zjfvlkN*<%)^L@fpcoc1AFuKWgzu0_obQHVfGTaW@?ZbzK<@m!nwikGd7&KP)M|y*9 zkJ9u7^PnK$12|&sQRw)|K%+qyURcR;VkwIQ$oqInLNb&A45C!8qfJ>pR8Zz&JwYwh zy)G#1i0BBlQorF(kRKTF%i%03m3*995Zd&Jng;(npA5#yi2P2bN;0~mCLttgq+@dG zgv9|C!6ejlXB4n|RKt@h`lNBW;Dp3XTeqLA6{Z42ree%_Kq2shn<42eOV*G|_}tDc zpegW!yu;4YoCCvJ9Q$|=@O@nh7>lR08q9k!zM6*%P-lP>B5oL z5J0-z1LY{>iuC3q^!V5BrcYFk$7>vFKHr4m64-yr+Qg*L@JS)}1}y~XwwLM!iMUU& z{-vap$aw4riE8nCQsrPuK^*0rQKjZNt6DEE=0Q1rlBe}ld5;)B1Fgi|MvJC{>l|rzmz{bDecppZD z0uXL5R&I#hA7?V{Z!Yw_*VkPfLI;DzatZe&?i%A(N1{J|>A7VZXffc%o^ug(b zVJzO5>%(#sT+H0f62nWZj3c?GKr*A33<>hYxKapJAzTH8?VFcL5dSqKac|ErZC?{e%P%8wb^=dac5utWa2Yl;Z> zF7PxBs#vjabsz&R>ERI^;ToJA^t(*+w-8JLWf?SRRCWX*ETsFdeB=Be5ESYtpl#fs z9arcxXXm2hImZVA0!{4O#{>=B z#9Vot8uU@0Z64{&3=sl9(MK%%AlDXfESjGenLu!TY5aukHIJdm>;a0f(*hvWS%E>P zPm93bkoh%KT7NIU#M0C5VBvq@z3tf0DY&mX)5h#%Hs4UuBME=iGPwf96%oT zc&?HnbG5rt$7Jt&!JcK1J%?H()>%8na%Tx%?ia=u26v#?{Cnock6>Tr9FS*d=QX!& zoveTATQ0PT9dPX6l}8#^&*1@!#&S9uVR0NR5D`C;l^-8{FuXeR7kn1tX%khV$MlR1@6M?+FnqWX$ zaq%I=W#{ZP)L+olq%4RFT+{Pa=|1ho@zlL0_vP9-mjPyK1S%5ug?^f5QYvZy>rz(< zTkS@>urguEY(WrE zM#FQ)_)7*IlBk-OsE&7=zC~hu-;YLvcA-UtTyfwJVl1>m+DUnxa`9(A*7&1+nAW0| z*F#S59r$SFQQHcnSNuoVCyE9RZ0Y79@Z1Un9A}N$$rMkhHoDglB~y`5d?2n;%{%70 zFZAKMww~;MO|d7gp=2m!kqUX(9?hY;?S)W6iRQ618t!ZNq&!)_Ruuc)8o!bycj-`G znKG&*KUK=QuSUuR7|-NZ-ZMzNG-NGTnBneRK@0Vo%HY4oqt@N!W5lkiK0e8uzqPGN5m z55VhQ4yXk>O`RuK4jZrV=$6f^OcrtQ3Et;u|AGa}^-j;vc+;`puyfb$M6~v`|MP$U zlo03j5XF{I>L`|~p>w&;W*&oS|9< zL>U~VwgDWaMU2*-7OAZ6)cJtQBh*1?GSAaDMtka@duaI~SzoE$`13~TailsXr4i+X z{=oeU`XQ7kHzdk}hFn6}PzJ7SOmKnrR!?O}$|K=A&p=!1NZ%n4IRYO|m_1HWe}%pZ zyk~jzZu-IaDaMmr{|){04)R2*AO8yK!aLvzc!UjF+7&=72WSs`bnqaPkH42H#%J(4?PAQbNzW=Flm%_zeg8g9Ua`7U9<-wWksD#$Vy9*m zNT1-#ZT&8)w2n!CCw&LMla|4?Xq^v7 zPx6pLJt-s7HC9Jmicd$X$M2W@Tb>LB{t^8`K0fUofiG2&r0tECb+~#Ssg2Qhgzq75 zeBXSB*ePRSHV zra&?U{tgNxY)k(gZ##+K;WcMjfH83WLy3*3@P2CXXEk~3h*su5)ShE`?Z2t6$I`Ck zya^PD@^`}3|K>Nw?%yR!l7+Z<2GZXC$1f?FSy30F)oDwjr<+F{6D|32}6O*vP6OVL&8rLx5cpY&2>5k7buu9_(6yK zfA*8IDL44HE1t9Bu&g(Tvtz}Jgo&s(E>{5oiTWvKw7F*m|LH$l=fCoK_7I{z(nf!| zWreTm%XniEExOpLia2 z`K0_q{qa4=jSsgz6bOFfJ5Qp2gR7uj_!s?AQzQot-`Ct@e9>ncbq+vO(6}pUG+>OM zsC3*XHlr0{wxQ#aRupCr0*d@e?{h8W;X4UJFgTLtwdINKB)#r)>xtMVV)T9}o6jTF z4-Fio5C!g6+>ZVVqgh!PeXlFlwm-B&eJGPQ#tHR$#}1tW%Pu5$`MHL71dOD6<4 z^;xX}bL)V+6$bZe4gS>bt1qY6>AIi9VAP9r#QL%lQ8}<(Xai3qrSXyaowAcA>jS+v z`cA&X^c(~sGF?}lbWL|EFn^PDa3g=r7uPd?s7F#-hH_P5s{ET|b+qOrz8T6pT)U!$ z#FroFp_@!D>o#n$gEho5LBOtFvss6E*0_BKQYmz62J6#kIpKS>{o&nwMz7NjVEXWj z=CBJ928n&YeY=9ErAVt)BYw}G|B}`(yob;DHX8pWrhPPD^t-3;k@Fsjp3yRB3qpFW zkerCvhydTANQ^fr21~&LMXZ0bWz!gnwITW}-+zy<;@hiyQJRG^!u`YDi_gQC7%%?* zyU6C96DW_enV6FHHRn~h4=)qoD!W!3J+A5LGU@34H($@tidq5kEKjJxU;Fu|%_(S6 zgmh#%%z*&4Gd^(=3rBfQi=pkpAkjp_0k1`a;TA-KX%Vb^4+-b}Y>EPx@Q#FgkOuCVdCI)DW?vJ z7Wd3EuNah8R-)1=S>5z*P6k>#bdC354x#;YdRDa`8hQHox|4_eobKXuCGLm-OE+%Z z)XJ?=sj`mmUu|*9iKm`j5ee-CXRLe<`cQtfP0*KiaTBIqV|<4RmS49aGz&L;d$x-{ony;$vfd6`ZilO)eA7{h@6JX@pV*wzN;#h zDASjh#_GfKfD}_+NaUe^peOXm5UV$nBxO~va=NQlxq_C>f_Bqs#;bKS{RhQaX>)#a zJHeb&fVw=(KjS~~M^e59Yt{H#vvDvr)Fh$h5(;)YFW;S=zjffvA8GIt5wqb zIL_|_VTJkuBpj_jyu#Rm|KKm$$TKk#gl5Kn(q8J|dDWXe;&18;wEGP`qZwk~DW6F# zf4)3YMCu9pF1ytMC_uuqo^@6;%L!;SeGsj4c!$Z82u*l^a-ogy@5E!>?uY&oo#`*y zdbmV8!zo%;Q=W$t17Y(n>XlI^tLYzj7hZsl^hMIXrX3t0GV`ZC^D5|C7X_cISErsz zI;ehUoSryOwd3|qVFm*oItoI|5#UlK3)%5fI?^9ym9KQ{I4(i0z!QW(37v1ZPNuhApb2-Jl9F&>>*-$SydRluu{HA5D%uj7h;xiRm}@ zi8oDZBEdKmC^~jStSXV&!tn!wD@ID%ySkVuSEoxss#i z2Q7Ny&jJ;;q}blzaB?)7K?g17&->orBe#Mlg%Gx!K(xa`5~QRb6-kePKnOCJF90NS zG?-bQ_Ami1TDr>e6WQ%;MIR&v4D}*7&_Nv$4)i4xF=cqxYwjB*S-0D>{!a=;Xe<}& zL)gSG>$z91d?nS}gHiK4ea1YB=aN1Cgd5jLoQz~*00qh)ulLspK|tF!ZT57jB~T9H z)pYB2g~d#v4|zu2XaZoBh4@W;3X^x{&zCDv-xDB;quQ((EcSLR1gL*&Dou!|SqKJb zXuK(YjkX!=SWf1#GGel!52B$B%BbE{iavjA$WY_o3)(cdU|Q=G$K<=2-&zcJ!ViQ{ zum0T$e>uWVxd`7V?}Ya7e)#s8;m=wu?<*BF44z*lO&sja9f~mms5L?b8b<3UNJ9%J zz}wIW<1|5X1b(Mb+Czdygm*w7h6E2Fu-2~M?Dp;65H5SLX2la?!iAoT6m>L=Wik=| z03@$a7JS5H0Z%vT&{dHMZ*iBjy*LJF%&AzNT(ns8PH}8LxA$N=NN zs~j5Zdvtn~)88o*^*8b)rA}gU`jpesjBW5oLxLW}L_JnO)r){&2zb&~?XA%_!4#&!Y1dEGpW`y8(5ZAfjlAH3 zwu&%zqe99y*K%@`K!Lw;`SKOCxE;V05e=+Ur>+8atuHgh8;WL^$EH=5Tg;dpJ9ZJl zQn|%TS15YnW<|2==xWufrvOBgRE}ctDnecHiiP3=bCGJ35uzX zA|Xz2`(*>%wtbH~w`Fscd$xq~eu3@YDhR4`HF&mLxYa@h3(E$-R6;JL!g$`T7|fSS zSfw)n=vAv%y7A-384VC_pG^En8h9E(r(G?8i#QvAaSF(F$)%SmglP-+`SfqZT%N%Z zV5N!|ky)m@Dru2WLNC&^VN@6yV9l0x%bqMty;Cwdv3Niu2;PPsm`Tf zNKDMok^zkXM<8X$nB5J$?GB+cwUe17pTe_#;4){)uD;0bdi3Ze*gV0usjm4Hcxt0U zd?Faa(dbnx*C@*1IR+1wMbF%z;8D%aZmDRLYfVtzp>V_P+g+)+&czg^a-1Si=2G1~ zcyF`jtpyo(vk-n-X;2+hkhU)r(5$dqzG9s~i?0{orPjTc3Sibl?JDmkjDJi0pG}%m zI!%&m6rgLJ`sX1b`)G0zU8r{v#rwGU;&!fqKpEie3>h*hcE$z?&Ak#P`xNkKzS|*s z-rT#l>V3XzcxDsP=qv#>HxQIsMK^isS6Wp8Mle?^tsa`VdI+ebvHE6>z<0lM<;qrY z?exC7b?d7h8LS`HD##nh#&;>k_MEx%Bk> zGS(Yv{M~p%A4U5+Ph%*hqG?XEaY&#v`W)qAsZic8YSTt}T8XA*Y>cN#lTNWjX1N~M z-XOkTEFQ}vzPick{Nac1Dr~JB3bblKStGtT?_lh#P@E$sy8HzSx%C^jT69z4sk$pJ z@8nLaaE8LZj+G*m(iDiLOBahin}v2XSYx`qKxc9(NYw;0A^O929;gLDi_EHDzoGHs zjW_f)`p=#-+Y}>Y7jl99DNw%PdB)*aGrrLZXuUlHEGjgfGiR<=?3qml<;hb(A*JuI zKL1HUvl^b+SV8ROsSY1oP%$Q|Fq!<-!u*&uaXOtDlQ`KB4D=U-1V}7kjgX|kd_Dd2P}7#~Q!Ho% zE$_%Ag0&EH+<*VwZp5pvEB`J<;v3`Y3vO$vrXwhav z1ff1FR<5u?-LRnm6gpffW|*r%TiS|vmiwtR%kImj}lCc~kL@q+3U%wmdc29-oM57xrg4Hlg%DIS5^YhMCb2Y{oqdktte z3R|5OEKpGWe5OU31BC~zhkhttqJ%|Tq|yEQ-KlV`T`lf2v9Zr--og!k`bovne!ysQ z`Q=x-u4*UvjPPfRb6Qz^`fiFyiMCp>P(C+i)D#JrtQG>dnPRV35TxBr!g;*=u6rfC zG2NxGc=$sUffD$)8-yTqbDtY+a&OeAiOZ+`4YW`CuvDqiisJaK#oJ!FVyng9MmP{* zGLQI=*zP^9zs6Q#O`D!&rVC6mO`Du;KpfY0@9x^QyIlQsg_|Yo_;M10>6 za~+yodTDEGU!z86YJ%)39&E1_l2(4|Ly(HH$t(wGia?G{OGQ8f1K3iN^1XLIVB-K# zj27p#bTzBjvWScn*h>QK+V0mmqV};L)Lis@QA*SO_up%|%a$wbF1+XhbyzuL90n^pw|pYt5TGH!@Cc`n&TD;vBXEDi zMl}VpP)^a+wz_6wAO!dg0=z{C-+srf>hQX*UV{eGnu`gD_+fh-lMQB%nsw`{QyUn# z1X?_^`@Ff|Yc{Xq2u(`(%=Loa;vJ($zovQo0^UiLL_3xv|;^X!2*D zO%YQ!lotQ4wX0{Z9ySrRZ`aP4KSW1lvL^Kx2DYi!N#@tq{S@d)W+zPZXG6%Vajk-W~V0(Yl*x3CtX}{+hK;*MwBn!j&S(NM*`a za-Foon>q6v3AU=@)ymSE0D`_+tEnQE2l~Q$@NWGE)!Y};KbQ9FCx5i$M+Og;K**pK zO9^R(wcO}2BTc)kRqIUG>f9z$Dpp&cv~1Pb%A>CI0cDcTIj5mOsw)_fG>B5*cMf~} zF}qizMty}WEut{VkBJub49}8D_o^;kEmnN9=4V=rceF#yH6+Fb<+DN|-G1xsHmRRp zvAh6`>$|~^Jml)suImO4ywC0fmO`1USFbjH092TMWU?YLAro;F!9%)IlQm!vj4{T+ zryAEa1S;)QX4mbi?iN-R;AH6i&9^fQoET6Pq9Zb15afWh^u48*_Bn>zSm}Ag^+HIx z-HHKtVej5OM)SJ$su&nJ8Y`)^1T8l6NfSSol2FY6kikQ}?z*13tIr<24NUr$em7ft z@4fFX?Ozqpe%6(ygxr7M17_2&ct%MH_?m`kdTy)pTw$3|sUN)UxyhR?F-MIQh%{sG z$}2B3#j)?reJv_x+qNAI0Fq1&q zo;z=j+qFGTztX#_q{U30JVToFS2DYNBD-W}pHBow8zW^_{jrH5I3|5M(XL_p_eph& z)xY7HjqDk)KZgu?%Fub}*r5HQ%`$KY-u{q6V3yRT=tMF34in0hRlfXbTAj=hD)uCy z>(sQ>%bq>EjS*unOY|}Q%g^Is;n4DE_lOa%Dhz8Lqbv2o27PLVToMYk>{(V3Ayt#6 z4>w0;_XrtxSsUJ)G+8V#QBQM???8A^Qy0DE2mH!*^h=g__yGBrc1XpMA0Evy~S&B~` z(n@Lh;zpHU#x9jBTTVi6y9vd?gCE!AlF@`Z7_B4*Y2X>V5c?CWvY_PZ>dH?NLcLw< zb1%@?cv;K;H-+S0Mcxf@HgNzz@Zt-j-SDSgQeGl8CiE9&BWU821_iX`8B*!eWi;VV z*0%$isG4{UUu|HOL!zzlU{oBKNPwKnf`H%wxzDp5%C3GX!|>L26* z*H&qQ0kD|VM&RGJ5|uLX@WaD2ZtkovMwdojZ0IbL0|2Pe=jwDB#-p>yT4!OtDPC>%#wx*F|kZu2rih zf^QmSTIktlNrUTlts6Ax0hui38Dk-Q5C||l6CbGSVe^r&lwqLd)^0c$kqZS*fS}kb zA_oRec%zt5+KEPssh~lyU_wfoFmcc}kkesK(jI9U%LGzDGE{4Qg8aQ|)oOjqW-F60 zJ5z6jHi^{&vAk{KPVy=S8)@{IF%nLfxB>m|ausB|#CE=P=^R^4Y~AWn->C%~MB$le zQ6QgSQzZoi7y2u-5n4f)tTG=+#MyVttUpm^B)?yc2zO9@pFL**8_ZGRYyGKBQV_vO zAf=F)7)fYjfcuidp!bcpONadfjSCmd=Vs4cp!?~qU*k89bOp}}1R)7U0y75j)bTz@ zk+QI9`}8-ax{wgWA@Mx#*t&hUOo2tMO*wPrk`~_7-o-uaxjQrwlW1?~_fUqv?tiUb z9VbwPirP20$$k0N7XsWEV3QhQiSZr+NvdRtQrgIyFTUAplTKLWg>sUX51V1%&6ow6 z%WW@4SM05%UD{U&P(qveo85G5N3wbWlndu4eX z#{&=EDZ9AesN1C#Q$b-$@*JC^tTqFT8f8cO5I#o{NYn<^2gNERqj{{)GRq7{-YPQD z82{=!+C5+*I3#@X@%!4OPa%7GZ$tF~iRVk1Ham4{EAvcEqX(un6dY*71hr(zB2!Fw zA7zF~o_dm}ROvG`QFz(`G!r22eo`3S$x`%C&=xIRAb$6rO40tIu0byF9Qe5;HbzU8 zEN66t_l{{A%&3?v4wnoDE=I|Gd2_q5 zZ@g;%LS6tPNI`&w>vS^pjj!`z+JL~O;~Hu?ez*kF@H|4wclKU;j~6pCsOVYj;iM(Y zR=a}-wup(LOag6qp7`1lqw<#d+7L!s3e*)XW1LJ2B%si!o`2RZ#n(OR*2KnPo|sUj z?xcKjr$qZE5$l;`G>dr?@!RSUYrnrAGkvM9Oc)0b5ffY9F|m4+$2<)L{4w*pAFaHC z1q*UnKnU`HQ39)Tku)n3<%fTEA<>`2w7|?pUhL$223qn6eBhCwVnY2QJW}%O`h8I!z&8mm7 z(yeMo6c{oK=YI&965fr>m z{5@aG#fyFvFh^m##Key>Me2Ry4TjKDLYv+0m#uaUXg$oVA;BN1Vq*raJ6CS+FoC=2 zmaV=&Lfaw~50C$)pMK;XWs%HYxclVIS4^741DcKZxM7bClR2flX29mQkpw>8+i#6g ziPAi^+~OHh=w8Uj#$thj#SD2H?Wj@XCYA%R-ZQkk51Qo0&0Dnmy-J%5lWaC#FWVL! zjnE~%t7S`e&6ckU;5UDPlEUa~E1U2X17qbrvpyS`Bqkp^vz$=R`G>n$Sg!IJzC(C4 zNwk1q^9q~^!^51UH{TpCj}HT3JcqT9XP_H(^1}5eF1e3B`cNB_C5(BVdiq&I8^MJN&4@(R0^{&( zpgs41SSwYsj5ebF&)g?(m+cgde##U-C}BvWL9Bf|L!!_R+@)~?;+MvZ#g2LI~m({ zmgYNf;Jxx1nykU?1tM+UV(#nCo z3K9Yv%`?!1Yu0x$o`jgtGLxAI_SuT#U?Yefd;9_#yT#cyQG;zgD^4DM){ZYEQ3Y45z#Z%psjD%J~9&zr!nou!p}ubzFi8C^i~=|S6^G&CzV zU1co@!gQa!h>#nYK{st!rA_cVWag?M*vPN!UHepbtP8jGzfmT@2G*CXDuX7DTri$h z{YhVG@~B*?iVidNH#sy+P<}WeAgf48hLm0VEB_p`CXRshn<2|JOW&*A`aTlc?}%Yi zo7T~C`#x_Xj%m&L4wg|APU%c9J0H z9cfNUV|KB@j||e1x3J+S7Asav9y0CZ_68ne8nf3TI7r^@YrlPmVVN+tZPzU6rGdFW zaT7Zu8-XO43hkC9e^~y9NrUo2J~X^x!^-MJG$J)rlq`A4TefZIM*MGoGmIub>eL;|fdQji}8qgup zpX4LaF8y!656FB$3fe!D3zL1@c5OA;j??MU+okRGHRgY6_~Y(Ft)%b~-neC_p5G&{ zli2e%LHqWY=BP3`FF+m!QA(SXWy(<<0&nr&gnF@DKj`kd`*ssFuZ|dHqX<*d9d`}X z_bjU94x>-BU3l8iE};Cd^H8n&^Ez$Heq^S-QLm16 zO@vkt5Al4kS(htUUUk|p@2l%fcp}`_YV|tv`ybs)5{hid1JHcdSvx?zP%wR<6~8j#CE-;*;t*)Ka56FB?Loi4KK5<7lwN9H9Be@2RkdF(umL%T2cZxBs_8}lewMe} zovv@60q*JH&zg@lSG`XJP zI(F>h+PA-4<1$W|xhr(=C!5uUvZyyMrKFxcy9qS9pdDE2(4mv<`QQ#5gm;i1miYwy zIj-0#Y@p%S8F1-3cD&kz+iR~eYi5*YUdS}8GNnsMuT7mhgO)~ zD^;rGKKuL&9TfOc8#RsGtU2>_+AFKlkO`N?OIN7OR5lA2EmBzSM(YiO zkoPe0lrB|V6U}b7LgnSol}!vXn^IJ_?$-X%qhF#})A*U_a>+$(>9Q3jd}w*862&AW zcFOH-lhuc%7k;sMWy^a}AiS&)V7|pmR_IzzF-ATuS67=L=ae34){jeUlMKxT*D2}~ zO|%i=TB3MSX`@@jP+JW68$#Gt2JvGPgweg#JM|{rl%YdbSq6m<-H|UFE%^z zJV2_T`Lj>6P^JMC1lmfv{E9B7QH~t>g8jyQ5J6qGbV(DsYjiZ6_k?yWS+d+FLdwJq zmKA21GG*jR@q>Ae8zdhAn2i8@z-z=f{Zx$$)`Qn--D-g@H^_IgSb^QMC(%T~sZ2PR5R|E^fR zMD#f%*SpKa3)$R9pM2t0DI5X<5c-6}W$oK{vbKyE@mxGTqlbs!bH>Xm`Bdl0o5P*c z>@4xhQuoz2Kf2mAz`Na{K9gBZ89CL7R|kETMf3~ti}3tnVqmf(*&!}XHtZ$TlUH1Y z3KcX)7R!@myiFKr&G0Js@!Tp|vXq4LxBgr}!ID-6CO%Gr&X;2Ns5J0KjhdQQ5V8y2 z1?)Oc-eSgo>``plwA!_9eX;geGRuMBd7~?vwBP?YN1L^|RQJ4Y#fnt|0~f5K6c66b zDke*oESmhDm7TtZny646aT_G(63afT&|moHxP&myR7U@ z0-*&Qmc%J7W=aPC+BgXX`N8JOnf0*mhv8VJuG_HH{jB^23+0x;#g6!kj@hr0wqGD# zD8b0ICxZ@yfLKr6@LJiqbC>e^x>@`6JX#Z*e%rQe5EyIW_;#~a3 zKbVxTr9r=(IkMREn>VfzV-+&5hz9lRnP$d@VD%aml)tQLy-Y4i6YW|T2OxD`ZFGS* z$Q3sH1RA_>rPkWmDbvZ7Fr19^li8L{aV~HEA|_-Ilq?(LB)m_RXGV_f@;1;lPMhx7 zwo%%u7YLNgVU)~SbE;h#644o9!(im}V>pSID|ZoF0RhMi?SJ=q-Atub#vYwM+_q)C z%txZ7>I6a8%EKd5mTdM;e(%uXuH8Gg*=hsZIX=M*t`G!_)2Tdbnx`h}HLKTZGd`0J zZAIH9^z{OTYyyIRIRwLb2%pkxk`++0coF`fO(AGkegW<>@onC?%H_#d)Fy$oq8qmR z#3_Yo`P75@g_Fnt9K)~h9A-w!N05ipwJ0Rid)tnnZG& z(nQ^%!G?7!#lwZf!?Cj&ipN4KD1`awc!cK>GrT@Lr|@I2*(ck-P&u`~?K&bv8w8*NY0x}kNN zJzvi=Ac$K*nIU<4Bmge+9?GP>&<;kW?o2{FPyU}&hgGXKXm|btc?slngC2gs4j7ay zS-}`M&>_4deDW*|nTS@p$@`!+*F)WC8v_wKa*cM8_#i*7R0tVHF?IZ-cG<(RKa_#F z2PUVT^aXT{);lR(5T+UqmcYFp0~ILA5u5+NY+>zXeaqdv@6i!aP2xpLV)~H znoth)HlZkb?Gy63x-33fn{)vIOWOkdP)8WDLy`4G;be14M$x>ILp1q#~7pRKvzC#JGAD#p3glkP%=WkP!{)TC(rnPI%vvB$jALCJt+hJ z3im@f;gkH-7rQul;UDT6#t-=p*Jtn+&yc7)VPLN;1UPw$FFpGSow{_P_+mj09wKVh{>E;tbdm`JeWt=0)s5+)Fi{W92+Q!)jTDR6WY zK;SY-tXjz`NqZlPDP$7(=pH|5b0rJ$leTLTD#q-N*1?f>Oi8=t6_hN%6X=utEtvvG zOMxKBSp}eB{}<9Rd#C&t8vSo;1B}N~jh%gcO%~w)whm0L?@2=e6e2U}$kY+BjGZ*~ zJD!Rpug8yvYX879!6od!!g&7x3IEft#4f9yAQMdR2*3Fk68HGcfagBH|CiH$X&fbM z;{VH>oZR;R1O*Vt>GUt7BokY|2x~mA_v=gW63?uAc+hhn0fsQ&t^4FnPXBHS{3#*M zGM^N7fR989c5>Ne##7{Eo&jkOJM;wX$L{=lyYGi9wC?_Bhdne1&wMbckE_?YDmz;ds#C`Qts566YbV4jb>7 zD8%-EdxAFb5n~Pid{(Tu?iz2Y&-sljFucH-is)VvUPPRL#oZJBpsasmI_`~48l2z? z^jnvuKnw=X#!cJh`|JbUgzG2UC;bNiDp-Jqgd)_J!txtGT0stz5NwFRT%@83yNeAw z>K=8nwfV284{T2xgF9Pr$vjfoOyq>C4TneYkv@0=?32Df0C(^wEb+rf2>qJ`Ib#f0 zwWKbcfws^-g9PUixyDtJi{xDyesM*`!#}0V;O6f8**&^rpIafm-2O`n*E2&ZcWGLk zwpOIVtwN)jw)23yWA}cSM}3z@g9=*3`3n@r(C7HqLq85?k?`NfO%#C-ACh#VTM@{;xj{%hZOrrG{7J- z_oI2D1CT-L?y}x6&@|%KZE;m9o#y)YyIpw?*oiNYfV`HG zxND>JgC0P=aX5q64ow<2Q(&XfI$@XBjt^UX^e4PO>AOQ6!l$*(^3w=K5Q)8-_fSUk zdGeBhEa5)ogm>{hyF8^!xmAy3x zH}vZ3F46J)_s4&%16V8E1Bw?^t5#h@{Q_AE#f%fJ2=}&aJGk!MuU9DA*><3Z)2oN8 zQ-b{P{T1pF>ibWAVj4T9L<5=Kn8i4v%RjU*j!%Q^kzE*jES2f_xHU^$t(w&o_hg)6 zd3VyuzNI=woY9W9=gg7Kj#IPTr4tZ>q2pi%0E%Y7AzP`EMp_CUo0nW-n7!C>14?y05s$+G_na#Xf!t-3G-jqXKcn5V1 z1A=@UnFcnEd)Zajz^uF<4;jj*KPaDxk2;hsQ$o@Hy1CQJ6AA%q?BNsd&X+fj-2}h@ zBq#;OHQwRfluw%}o3IX{kLY9W@jKKpr|M`zL(dr-S8bWf(D+yJfA>+>j(Ou_lZyGH>0WQoij%BPls79 z(KYh$KImyQg+CN(B8~b0;m4S2+PJPGf*-)b@MuVwQL-qLR3q0X{V%R`xian-1vL9b zAsn)sS0Q6+H!?>?*C|_Sm$7m+_e*|-p-^QH>fM<%1Julr)?KE#v9-Xrz!JlOz?S8n-=b~sgqrc=I2<{M)(|h!8`CJyg=JY{NdKG(Sqjo?qlGBmQ^x<(%r$e&jMtX8_gg9a-|)vKmdQbwIR^%Tyc zmD{(6Db877tN=bi-%|$lAf0}CMc1n3c?!Fb%T8hwej)IhWzZjcKjYW>H@l59$~ak4 z0#_VGji@$#LT7;o_|y|Z@6ZX$bIxgK!S3hkTG9l->xfrh5)#PQx|P z=I1Cm1Brhi?<7qui6I_KD2~sDM8F+{5gKz8z?b`!2{T2(l(`OAZ}jMQU?fghapH=A zP^7QF{@gw}VB?Q$a|%$P2lEpE1qdKS=aUGkl=_e->nVyeJbU)Hc0KwB&{YB!u`~^K zbKcM7DEX79ZK+Plt{513?Sxu51`LQaC)TLXn$^TqNw4@4#p2_IMBl}Cjt$eU5KkoW z{Y<)F*l2-3*{E16QBY%mm{X+if;k+y4$ngqt`#pQWPx?T#cI{k~u7MN?ZK#=`p z#bo2nEm4|5WB!qtfEc7vkXx?PM`(C~ujotKy-{Hns3S4#^XJWJv8_WMo+~2ygvp1~ zf(h^WSwd6qNd%zm--ErBNigBqHYNF#Pw`T)vBH;0`HLJSOPv;fL_79~9mw(#8vXJ8 z#N}qz{es4?iLd*oQws>WQpGY_2~KxiI(JqeskVk3#a&Mmfrp5u#-UQfPQ&O;SYxer z_kTWByO*M?cqwh_UtQxYr?@P#`|cNz$FGYPxL-GKbSV_lHI?`+y-p<-Q9ieySUG-d zt0YT&a=Mn|=p#lvD{b{!p+vQJ4-dZACT}ub)w!!hxg_SenXAOlAfhuI=q{45XN^=^*J+I;0?@_ObML4^eLr#|AY6WoMcr#eAVZ0wy8}}dBXK{JftX?F(vTPPBg?%k1QWUVHJVVeeBD!+_pH9al z#1V$rjL`@N4H{~YMG*9nN1jkTf?--7Uv9JZy7lW7V5YH9LPjb~EbtHWm~C@TsRBwIDPbB;+4`bhs;8w3txde^8XSjT(HJEn*o1 zYW3P>hTc)KWGMw^TCM1G_bQnGRuiuG3HNh_(9vrOT8gN0J$rRC8sB(hKfzbklMp!M zKKy8cBe)JYgD{v$&R9`;^B+Z_^!1eEcr!f?PjH*_tZjlP_x$k~0MK`)a1#;U!flHw&wB4~S z&JBKen3kv|g+8*?JvsbIH&Xz=r0cJ{*#a&NyuH7IQ028CSM3K3aFYcA_uhMB1sL4X z5QD%81q+k5j(zhD_tsmZ?Ri4|62qK;KaCnT78C9iEZp-3o?Bk9S`3`>LZ0b0=s{_r zxutC_cQ^IER`Q{++q`L&BUn(eVv0whzN%ZdzOWyM39O@*yYNCGvTNXVyQ-VToE|>> zF|$8%3>;eUj{$qwUvUri}BV0v-iIEWIVWMQUhp@l& z(ktD0tC@+x0fa zp+Uhy-qgkQwK8iy^x&fw^qXx|U*07h^ugJD8!Z&uyvTS`XT@Z>j$_9A{7*d*bDylkI=HCdNGwHunwR3gb#^!LdeSeLMdON4FbY%cIk4pI=Gv($0D{I zDp$6Qh4ZXYqlVih^l;#io*Dj}AxOhU7q+?BU4Q)zV(=PPhYbp``te8ai>T+@YzdI= zEq(jj03id>!an}^b1i{dYj!;ZkXxaf&|o=GYG` zd@i=N8UmAj>4ldSp)aq!i*mH+kA2@QyugMMLSTV5vg-i)ju}15OgHQwAiSB)5rPPC zVB=Z|8}KKE5UnhflUH9EC3Nmft-MB!n;7amVLP{NRS+sIk1xFN5_ft9AJZCe+N-X* z+I8!CjrC30vgH(B^bxhCi#-n*^L3&FVASw%jT*Jw3rq-V*VSxZ1V^R&1AmoWtPYCFEi6GQac6e}H&V9GoHf(s?=CRwyPdm!=$0efyRudue2*Y$hvc|i4PYQdwh z(ISAmbm?Zx@(P;xpL~3{utmLzhqm#)R;^m=eZLw%KRoCWdp7{y88do^0Kk+{1n7JC zlSnpfYC74f*Pe2!`+4bNcW}*Wmm+gkshcLYtqlHrh2LZ0VP*s@83^D7{OWKjm67Or*_VV!4 z0NQy6sg3%8kg=F+k3RV^iVT6SojP{WWN@mKrxLQE4^nxZq!28&O;TdD13C@eOUcYS zWXMqA>t0|{7g@del8*k(e|64d65>Q^%Ov+55J#@K@?z71SFT#&+O_u`%O(Re*DJ5Q zByIaNeLiQV7h?A^+mWul2GsV;iOb>WzTY&(VFJIIU*YCLF0ehZ5XiAlm`uAq)za_PAWUav@NGL3Za z*4^&Y(b4i~H`+QN!L;X&J8v^su7S7RtEHQ84Q2O3kS|-hz@2&K*%n;>x#yp;wpFQG z)-;+kgaSii=@*uTQmeGQ|Jjy`0j3Q4Bgme8Zn$WBj^c=y(@JBvyYh+-_B^q|iG$py zZ*K|z76Op2C;M=EG3a%6zhB>e#;82kuKfjCK3`%noNpU&tKFw>dB?C}Lkxd$`SKN- z1Rk;2-SpqX4?k$2xByVI)MOJAa{+)E*d$+jeY8;38yooS(xr>!-j!D6dSoGDVt*@> z8^Fn+w|qKzq6EeT_FiIZqum7nhKh>y{KJnj-Tk^v3c)agBs-&0DMNbiDgfy6QYNms z<_3G#`oq5YKOL^@`(?F}0?2t~b~CWK~M{VC+Hz&k*Y zAu(BjfKEGFv}`1eY=ZIY^NM@jrp-C-;!7_U|D@Ao+uH24XsigUfwvFP{!vR!UZ;tM zKe;ZQuCnr=8>mYFQD30viJwhd&p-bXcXOZX6u10CQ&s@%C1fp7YM5zQ@g4Fta^K1m z<&d=06fz5Vk!?2;|6zQo?y$NZbq6KIH_D^qbN$Pr3qHJA;z#%e{Rj?4dTiLE);5$F z6o?V8zGBxgbv*UtliCNe{u3jsJAAW1oBOMJ|&-A26SM{`oY!Pe_WW`NS^%(Vy6u=eYdq@n{hV~~3J z?I4xu=#2}-l1#U;GG>_E zmJ8>GF0#QdWeQnc?~OI@{Hb`Rr(T%UQlxfP+vAaXftkI{w3b__2Pu;*j;Bau=z*Mr z{A)DJ3jBfht1VVmUi+t54noLqB2uV^5Qk;JZ#%*z5-5jwi;KzsO9Dy@%RtSQ5zL1LY=ABw0CsFMgqJU51H|OKi|geVcjy zzd!z^2+L^yMvP4xXJAi~f+>8u}ezR1kXsCL=^mZbRX-l#|{MnkgR1NxE?0;R=L zCq%cL0(M9ER#yR7oHR=kSt3!XyWw%ir@}AL)N3F47a8wNOa=6ZIXj(Yz}pFa7rcas z%d|y993H>-dZ#GAkZYIEE$JW_8BQ&3Bb`?R7gb3uc>^m2Q-fb}2^MCoI7iGM2`m@u zzN8lBiFQNcgflAb+~dh5Q?9Ae25WE!etZt_JZ!8--ox>I;0(wQ4UbQ=qS#Rzj=g&d|F(US6vIbw&c$?=jveDU3Th9 zM5!+(06CsA&KZzxURq&@OLling<2^;oiy0Od3)q)-!wDY!4+mk>r&m=dco! z@i6Nc1Hat`?5LWdzNr(;K6aiy72>a;^qC{;691%lpjg7cU=i8O+uR5a%hf3{iI+~GVOUl%X7%Mg>XM)@$QtMHwZt6++$SR(rcQIc@IhX73ZH1mG- zS~BWa_k1}0qqU~F!8QL~F@xJIo{XYZ?j74v0N{ufhUkj6hJZ%+LM_>%57*w-Sb(OY z-hKA}C^!mHBx1!=-r2q|9oT%z^RQzjx&JSHeNzWcA2Ccd_m2#r5MpAOInQ#0c z*qpMl`I##>NWMc}#wKo+|NB>Xs&6gJr&?=`N7CXyUS1DOz7#9n{qXW{*v|)R0=EW= zyHB)MT}b+`hmI(H0bYc;Qd86_IS+}8w>=H?VLsF0`PvcA;O8)Q0$#iJgilVzPm|)4 z!>BV4C-aie_S_`BCdlU=$NxwlntV*M3+1!k-tuV!HXUl#D6NC+2@$?ksUaXB zO7I8O9+e_%26py^BOER}b=ImPNewmVAi$AE-K6r$;l{#zSF|~`dEU=k9|bbKZfCAG za78$9L$r8&r5@PaLEiQGDf`!z1nHsG^YuS(Tdyc*W;M! zW_gFF$@d=$Y>E0=(cz|bO`h%oyZ#~(MgcaVAVfv5CxyLJ{xcuNX|7Uc9~+L3TG`iM z4%b}B29ASOdOsq0m@oq-WdTKwacm^%CMVVu8Qh>ZN1lYp^Cc8}y6Sc|Xar;N9q zosDnQt4M|JH(k6ZS${Fj8;A}%XvaW(9HkF5@C@HJ4&U*&nFWwV_mmCWoQv0Z;Qv&Xg%@t?~AEs zp0si?n}L8qt`2t|G&s#D1&i$d#pnI}iQ;s>$dAbIVnx^eQmjVBL$!h6&F-vu`+prH2sa#k5qFat8}$shZ#ozLU) zOZX1fFwG|ZLQ3pv9VbF-1iWM?W1yzFR z68(tuHeCnzmSq!9>a+WF`j7im%$oE=6TkDb_G?ctmA^O%Dzj5AJE+zNloez2;}r7hnD$ zO#c&GfKKw075fM~&))?Bla}Woett@s25Rya8#6g)fjMq6Q@xej3!{DifgRlN`6;AU z&@I?9a54A!7CZypvkRmA-7#w2Utosc;sZKJ>F&uhc#+I0yWO6VXkHNvmJzox06F6b z_>9^VU_7cEx2yv-J-6x|p=1c+F(lEOjAz#;tR=N-JCC1te`i^!$bv<)6FN>Lh6v80 zzN==n?gUB3>jRycAt`Kj*?%T4im=K^;$sOo`J=S8H#nZFJk3j^f28sw(bp-hw=#&OhGn$klMLoUER4PYW|)v+_Qg)`Rh;Azv;pg{&XDPJY4Q-SqZ=wr~zyMp+8 z(QlJ7;qGdU&A32uW+8}Rk% zfq~u8Dueq%2Xxvj><~CMEjJg6C0@T@ClE5PfZ6~}L^&o=T~kOEotZCn%gyaqgKsKf ztjnol@sy?QlT2$?i!z1ik3O_v*p8&cuXNn6S{h?`v2?F0S^OPzNDK|9m^%}~miG3_ z%3-a+V-yGsW>U#Ab#&O<`n?~R>U%w=zBe~xEQM67Z+|*ds#1r+zLNQb=1)e(Nh!)^ zkYfkAVo)L+Kyy*%UO4wYs|KIV!Tv$7p92=Ywf;LoYM3ExbhA-<6Bz7dB$|3Q8sPbJ zocZQ?aAe=g^si=pa>W1T^6!q(WvnO`snBcomnChHD-ufIs$XLC&3zx_(|4{N{*MvBX(HM#X3Dlf6d)(zUC9z0d_)Hu(=xv*D^1MKOqt>})u zoi%96hR7&3{cEtcYwZkY%|Wji^lgCiPEQQ7hX)j@1%N?frbS)e7I<}#1MK;Tu|B`< z)n{lf?0#TxrSQ@i#L#C*a)+OP;WJigU958GHvm7XA4E_w_R@zbRBLXVnaELJgeJ#mZ=i9B)LE8B>aSOW)Tzo$bPw<^mC^yFi1)@Mt^mE zlebHUScO3hd|Dd@Q2uf>K>kQmG)oqjY+YHRY0cJLWt3YhZD#V69RuVj)R;||oW3z! z9M++18?rkf)pN;5)TFL|mSRn>quc0g+~PVWhms?` zbilH^H=4RzCkv$2?~i6zP=&hoe#^(7T5(ry5$-w&AYBw*N!Kh zc}?A?t4%t=-xMTCb3pLn&5{QE`ig<)XKCe#;j7iK$z2ja@ObxkIEz}Rm0GFX&rM_T zp2B!hjOY)co^`aSklUpuvf*=Jy=WedrtzdY$b|ec_7VtUG1=pG?>7&kxwdCg-CJ-48K5+#*Q}`F)Pqs20F-7 zs)!}wDb+I?|E(pxms*3^DZH9?UN!Dumi;5oh9^GsSDIoBMjLuBE+Sf5e^3Rp3J6Kg zUZ;v(b*_MOiSnt@s*2~-bUJ4dxdgxZxAR9`R5fDCp>%=lOO%Q_m8EZEbQ9=|i-H~J z*1zbh**Pz7j|GTTsaPnnvH)V2$M@o|TSykF$BqfM3`c1B3Ko+EE%!5fLWCt(tEPFqN1BY)l+xM+k6~MR1&zG(X2B;sL3)@2i ztt|)Fftx6?*s*hI|EnqvHn)$Nz<1M(xWsoiS z%tQVbj3#)tKSO7JjKM#{QlEwx*Z>qn^ z^nT#oHPEU_7LDj2WtQ7vhc7I7g~8R@fWdijOBQVjN1t~jlcy_9PHUN;!>OYx&-e8f zhd+bU!&KOS@mgx!@bN_OlQHZk+e2Po-%8BcWIz!En_>M3l+wY3pE{`v(n(OB)+F_N_sZQGq=cDLCl zD^&c3j&x!V^;#whNEr*Gv?=kMAZdTJy(Qr#POCDkaMft-MZ8+_)tlagj~m{rhg7>$ z2pbm=^hJatjEfp}U~h3i+{0Ar>|1-;=U5&9)`^7K10kD#`tr$`NL{dE4H|l(@Q3NN z@kToC5Z%hTY2G4-B-v{O$)DXO^~91!lcCG(D(JQ1-g;+Jx|7E( z5KOq^z7leIA!YYu)@%Fdbl0za|EdsEpa*8#IB(C?JMQT;?O75*i_)O~-|Dl=C3 z>hzBoQYua%w?gbZrz2mhK2qG5fhuF9f2>&-XExqJe_IKU`kD@$9^&h#E{ND>*8)3< z*mBkfFaDhiHJu2H?pB+hdBD+=Jw>APmx3@USmH0SZ3uJ4m)2A8lAMI8 zA>6Eril$Pm2^=lUC8?lOUFv;$abjsSqMF9I$E42=O4L=!l7TZnw^0KJLa=}8p)ob z*+1gXH}5ON6#NptmebTQSmzL$Wx-z~Nk5;uy}qk0#N0+4BhSN|v4;{$MRR+-Im6}b z$GW^|nGRf#4F+X0V>t{jB_|6K+p2(R>F#@-e>U_8|i$k!YQW~o9Y3-2rJj}%5&-ptgd zyHXUlcv7guys;{B?f-Ci>(y=Y!N>j3s&Uu@U*ZjB<_E1kYo)8jPj=~eC0+G3*-)-U z)mnAq=`w^YMdSf&1iYm?PQ10VwRJ&Qc~`$OJ{H?7pGqZ(HQa-p1*k|Q#cm^ zUUyuFOOvDp0MQcau;bez+w1!R>W{C!ENv$ES5x84=Ngoa81889`R^AJg+}U>$h)Dc zCQ!H9UW>E%mk(<=B{b_rU7~TX4;T{>6V&kEqHyIS8ZcrjB;YFquB80}U#K-`?=uft z0o=vg|N4tktvAS3+^9v!7<0^&QZ%d*B{=dKo{eX+q`ii0C=JpE5Hvu*#`Czn{pYD! zK9OtRysmp-2A|AH5qjDCX$XbGvES- z2N{DoIX`C!ymbgZ>DCyl6sTFVYVBKH$0GAz^lM*qVds47EV7inew})?G44zje+Ue3 zV;GY*x^$BBMJlj*dwKlq-1yHg)#WBWElAFj9wPFA^B*#A^W!3h{Vp`{F{J`{%l&BOE7P?IIc2hz>zDtz&f9AX7S5a1 zNTw`VpO%_2EL!Va{2nVye_?D|9MsZC^?Iqvci0_I#+Wf2Z@^0j^?f9_V?si(T)ecy zpoN>p?hePxSVL4VD|hD%f$a}MXM}^!9Yk}Mo0gD&ecLP?N}*mfo6fm+366gWMU$AJ zRgXCDd>(mP}%v^>SRCaz1izEd|QBtQ7@2cu85@`$}GOP-SB^b-zaD? z8^UhcfwFa`cM=SGPHK3%gXcp7*zuh%;=U0^)2{i7}sihg`UV5)%LfC6mv54g}kn8B4g-4zy>0E#rY_y z)a~z0ndL^MtZ{WzdW({rE?VkG6`KhFBHVRX#j#S4BBZHTSO4JE&1DhDM8!Tl|X;t9m=Mh_k% z(|s+a=)Yjpk!t#(&6Y#|R~zeO^|v31`($xo=BM7CWWN!m0|+*N249Xu85fE`CT-EvOySxR@uF}E^cqYTLrRFr7FF_LyydpkP~jLGS)nnX=A`aQH&DMX zbDkoT?H9W4{TI|c31w@>^kRQpm@J$%MJ#*m^1=-MXqF(k<;q2&&xr>8(fbKHkvQh3 z8p`J-(JG0&ux+D<8f#=`1)(C|2%#8Vbw8vMW{kW4NWbUbK7YnBuuR`t!=D=A_yt4g z*|sV?-}uxjOe0S$TIufhoZ?w4Mo>9o!lMQ#JL4p%>LHv;iOd;OA`28l%n(o ztXJCr@w)I*A|Yo%VmN-L4QgnF__PKM;vO~r^)Omlv~J;U7gh6#Bm%mfmtjbFo-7E= z-Mv=-Z4zMlt*Qc;=(x|QwPC$jx=S~r8DmKv&^`o?-HxHW>ElYa`{|(8b1d(S@_52 zI6L3CcfamCBg)!Oa{w1-oGbxSQpXEQVAcAaaOB)7rqrrZNTa|n;|87N0i&~7?~jXG zMN9oB*-(FH=Wzh8J~f@4`JN@7rVX37Y}6X#=bz})5U?Z_;^sF%eVJ}p7(NP-C+oJd zTnj%p8ls`O;6Jh%es`^99xv}~koSXx&FS`g<}+n>M99ao?yoM6lN*w#?03K5Y*P8O z02j;?KYW^Pt7ItJJFRuI?enku;6~)S7-=o<>=!Yr(F3h(Sq{%#&ViK)bw&8&cO}GE z&?kDZtu7SNgi(=<-5*Ngf4EB&+I$^ec%WTJ%t=k!Bv_VO^&{=0*w>bd8h5?=y_Qti zWGYrRMi7}JXh6HtD;!)B5Rk0}E8)0!Wmxq>SqG%2)=3}lTQP8gHx!X;8v9h7tBX$x zYHYm!g+%wTXRsYdNq&OjGSw=PbJG78f~Ny}CvlZ$fto{)FSth`kHDzpClPPr|7Zyh zW87|20AK&xZNCEFbl8qN42>ua#*rqjG6zmirA4ihC}&-6Y|TkU++-c{kssChD|Fd1 znbSvNw=`8E+nPpY9hmL4{I!nX^@o%V6h<&-1bL*u+<*;q#nG_I`_zHMxWJ<*luE^E z1`|48saF*@*Ku~_I1hS0e-{UjIz$*G8tY^W^sm&6d9p{a5+7Z~5^j51evV}oh>fyl6f0c6*m#qTX zr-0yY`bkfY_TOPw>;P^2yzEd#+6>KyxQ^sVPnf*I9vlt)lrt!WZl@VV$Bg!UJ)!5W z6S{zuxv>IHR|s0$8!m=Jn8j1Y%re%mtM1e5^hrjMjh9jZ&T@XRj1w=cS&kj5r*pO zmx8t`v3M8i{Bd`5Jwg2)uXlF(tq&PoJ-HYUY~6ovk90l!pA64Qc|{FbUtWZE}jdJ0Ul?f(ZG$_cFv_(!l++9N9AjF<@H-~>i<_IDar+jZ>`A8VOXKD z=eo!-ILMv4I6;rpC@dc7`J*<>3y;)}(X=ivSpty>6ma}69eFZ8%*gkyb^j)FxG-yX zw*HNx;5L>@d@-mUuGvW2HYS5R(AR+3L0o=3_-^G5Ud)F|9J{obvvYyD4;bZ)fDm}I z9lxF}2LafZ%|8FtYd}X1vTHd^OCbOE%!KX+|bYm|_-L3f`)MVTkS7`H3~ z$|a}^=gYHk7chB&Zv>fd>IiC+Ye;l!Fb(p1dTwCsmgSdpsj3_$MkDfB_VCF>wn4#@ zjMiFQ@uEpLWzXdC%~XcPv#=I3P*_(%v^b$D&UXqNEc`UeSn&2z`U?zrcCNf7<4t_^ z%KZ*cIQ=EG-^vI%#RPnpNyvaZgG8JVM=H>w-B+xL$L6GcdwxA3ZoA~s^k?pb^neG- zkNPqD+ZtKU&$>{8nlPz6#vkJeF3a?s@$3<2i-9VSmgr~kgD?Xr-_AfSqp^W%`G!)R zj*IPsBhjc8UWX2V7pg|SKrfUWS=3v!b?-N=%z%H)UR|nQ^&56uh@9CmUg{Ugcf|^9 z`6ijN3SN8!@AE<3mLRMjaCu-r?VUJRoN<| z^x;gGky}?1jX_zupBIY-M#5>id6f~TI$}6vQDA~~rq?@P+&EtsIyIKnR?WW&gp8$x(b@H5CZ;!uKbX_Q$yxWt4* zg|s}>Onk`8G#tiL@rH~wT_u};v8y9%XmKAIkAO8pg2Tib8uDN*K#T}@AWFf_V|sLg zS|Pkq5u)6+_=1K5qEX~{$?XTBr3U9WE>wjIm3B^SY+Ur6+V=goSP~PG>fHXYo?w^L zzf@KkcPRPx^VPb}ENR4&U2RHXQBkImsEDB&FXx<3=BkNAY?|5xaKotJ5pLlOO5%+I z5fMrSNmIlg&VVMMvOMkc*W6`;B?-MM)GC@_e!LqX=)J`!DZ@PaJv)xj8GG=$)5v?}#F~{zPGp?6&cDg;MTKhVuf;Y$GR9&=7miEF+}ja!`r!&g;)#p6bXHn;}r5 zBy9xZOx6GY(le{T&CkR9;UkD2`q>u4kN$9v!?Ue%`XfFbGuIF z)7$~CT9Q-Bf;!)=n2JhOvcR{;MNO?D-_H+)uVG=*nZf{p#3|*PWjfUH0Q?B&#_Xp) zWujD?IG5%2zau45kI{|`lM~87Bja_=5z0Iag#1_LSar3cXVBUW(y~lELQ zB^qQ-pw70fke^!UqwM@|KoJ&%9OJ}D?>!5)rpfQFkr@8R|KivTDPXY{nCY;4y2ayy z+-SFW(`?C<*XXO;@cpR*1ZBRASy@eg0OWGhSAp=P;#I=}uc6z#N8x_(#f}heZzlVyjBO{|P?V~?0<=FHQ;YZ|(ElO{2zWr#o~8JXx3@b2 zEZ@6yWGxW;i;M0x1|3(&pdhF3#TyIHB zzS@%!^dCOkY7RTz-AzhANodhIOHG>bBjDE~$TK=$Kx%I0tT;z3UgH4)hE_oT%6Kr~ zT)n@Tg-~KCd!XmhaRoH>l>xz6jIr>|Y<+)KpdcgB-rX*Y&K<|H1bKxOakHYP;}g4Q z=5VzWxGGg|+mNn=CL10$6YS~xZF9oaAa;bWe(6$%{MomVKz2Cy+2K$qnt+vEDRV@w zmWjF4rZ-gGvnR?cGeZXVpv~{`xKWaDa_}KUR;*r0<|x2-HQ{8wrYjgeT_A=8ZsE?q zJbsNl;S}Hr_;wq}^yUpdr3%P$EEGKYk}d|$rq%;$>-~9o*mf^L-5Ba&<4$Zh=y9fU z@gS(pa(R+0%()kT=*H#r+>{}Ar1X#duqG?RLP?f55rBs@{#=h0^V_8;d%&fx8}8xz z5;joHRp$%t+2D^DN*}}ET zg-{ZUu3yuFegRYTFE$X2egX(l#%SvAxnG~xSuA%l)G{S`$pqC;78j905?fB6Xd~7d z?20Vb%h`08qd|RbvaN2fmY%tPs0JF+CS*;5261|N zQKb#C-w?O5pwtxzLGOg)2!N-l=gbo!?`sP02!(LtT=dm8E@kS_e4>aY<6>x;+PdNB zF0D8cIz8DCaEZZ9V&e+IHcSb|zZ zAh{rk3)`RIR*aqyZe$8ITk&m^#71xkcq~=A0#l_*lY=slEd_mGsf?nzNu1XAu00Ai zj${1fIGqc)oX2wnF%)R)dPr5C$d37(DTG(fpdLhe(brMUe{w0tGKTvLj}VTY zQyGgWXxzIcjBsTuL=ce+2kj%C8)B17a$(cuRh z&}k!gr2Bg?B*4?vH?y+h)P&g1Yj{2wn55JiI0-zu{4N03eEN#;)TLrlUYnyeuTvFp zKDXBej1s(hh(aj5|JGgUrpzrx3N9vu(pkzEM&!(LSUGS{nsKRMJ?BfiAd(AKv}V3( z4_vd}G^YJLtc=F67Gr`qfKYV8lq_XaE1TG#RqNp+-fz z-3!=#yqbesgPCp(NnF?FbW2VvZZvTyT&g_*v<|?h+OfR)o)24CeN`aGJ-EW;e4{#q z9M2G@U?aS!Tt9pJQM9i#vBH9l2WnX{D^g>>49Lol%*hkocuK#Gah`3O%M0U>&{}Pa zCYaQ_jg)qEir>**F$D(&5X0}w_6H;eBx~8$g9xh(S{;qRAd$Ar$cMuD22~}@r}FAL zBEEIHN>@Q;@x#Y{Leh)=`TGE>i6#Ywka$cnMflC$`AFznyXdtHcYCaaOJ=vMygaz3 z^Gzcgy)Z^b&WjqZxFmL)R^ji@uuQ3s1pQVuN8QR3Ed;$y))QjS{#!B|WTAjJ(NLHr zI|nr|%W?~(EC^dNqy1FC%G)rS^lggs-Xh*n&hG+td!_=UNlni507R+DyE_%_T+e;< zIs6ccB%vH+hA$y@R%PBE>@osakM=^$#z4SzO1|mYPI0|ETeW^HpWp6n;X~f=I}*IY zlyU@30jT`~w!o>ZiHW3`GY*E_Y9JZrx9N1_ucRyEUChs%~6y>AyQJ2O6mfaxdh z7cUK_^95&|=Y((-qV|hlc$t4`x@^(NU{0@Yeayz6k)bt)Q*XC-l;$^lXFq2FFu+l; zMBL_?M%kF(cLWmi5W-KhzuKnqwfX0;9qks8EV97mU)jog`{+^!=aJ)i8Y^V`mQ6|; zF`^Xa$WBwspb(C8;ei-oKhYp$xG7cBJV!HB93Ium@lx?bUL17j(aw9iy>VQ!u+rf^ zgq+&HBw#s$NG4Wa@QlzSE&ZZQlgRFW692`k2`CiZ^mu9x;`k(VvF4?o@fpVzAIPGO zMRrQ*(&P-EOmdH2t83Q_$pVbP7?A1Oj}S*+#p?)~4(W zP9m}@Uiv!CiVNF_(DK_9|4yR|EUe4hcH5i7HX)H<)GSUYtE))5{AOcFPmgR&#(M#fkka4a6)y%7*g8=_OSI=ZH5LxJ67KDOs z>gh+b0Zr*6gK(Ty)s$Jp@mY3l#1lojr#52D(EMq9C|T(KYZ3Iz;n5{3T%v;TgnRk7 zc7AT~VPB(34!!f^L$E%2@quQ;Yac?t2v};g-TmvES=eVLC>D+@hX3nN(~VD@fjQ(n(g(~p_-#I7=|SUBk#uN(DMh^NsZZu95$9ip{{}W3 zSko*1?``9(Xx1H5QcwirJ{P=@QzSbigyZs2D%Wj*AnNpbvVhL&edzlCZ&D5!5;XPu zGkj*wja8BcVS*3-MC|+I!l%e+baw-Ykbcwo>q_11oaZcf48P|^Q&LU7Ow zr}Np(ZM&m=uFkRdYySa-9nsq_wEQX2d69h$Aicx?`vLSyJk_`!I`K~E$*H>RV?#PZRyax>p`r;&#vpl_%!n=I!55R#{ z{kYZfz3k2n&n1K3!9}7Q zt;IiXevq3;d5+Sv-;x_m<9-%@D|z8m#OpKGprVOi9&H}9JG)G&zeWinPU{l2sr%LO-2H?m8J*8zSqGY=T9Q4ge zrb{6|WCrj`8d!HvG$ld6=Tlv8PeZHBLhfDk>bYD*^->kZT@>zu;2$UA&O`PJ9Hd!(Ynuw z5xKRH>XjscLo)cGc`A`zvHr%>NAU4jG3WVB@1Gk`UYKTW$}ZhtZ6oyhm;^$@mc6&c z*HfAzCVjWzxk#cZoX-4Rs>Q>u(Qf_iD;WFk3|zB32=S8t-$9ogX)89;cME}rO}8^z zuubdiVzW<<`0MUCNI9h&G%*v!ioj$$UCa*1Z4ZYkI$!*Q8bA9rC_WHIs*8!2Fab69VNXxA9)2JugW^MF~K{0?i?criC0 zMxNzwlkx&6yD7RJcc=`x@@7@Uy#P|L!3+EjOCz*gaqmCsX?Ypcads{l4&^eYDH(2z zqb;gEtN3x_P5|43s!1Ww_*}H*)cy%5I3rIU*+!+Kt*hJdqP8-AgRvxzJ;8ZK%_om5 zl2P?GXq(S4{VM-F=kS(8HpJk}8X&vwahKaVWb;Y3;*WjL6_xyy8}W@@KW2noq-<4} zH~N-sw+mcKEB`ABOa{8kQ;yBUfQ3~@Gr7BaND-gS{HTrE^8NMWeBJgcyZjybT_^N4|4wlch#`%v5Kl~J zKIlFOd;O{D3NNt_euA1k1>!cMzzp|`M*6c*z2X)o%s9DZ+i=c-XjT1 z)eiA$AE0{gH@*ru50QEUBEl;=WaE02sU&NVsoD%b342!LX9 z0OcCi8q8fq%>|RCp(xuQyk-C+3Pt=y90l>S6LP54)5Tv|w%)S&Gc4GD9m&)Lsagd@ z7f$RbR=K~JgKCgyZs+`nbSq(#iGP8S&LU{HDOZ`|T={5kIg zkeA85kJ|!V;Qv^o`Cvp#X_#hMmOAUH5$si?_v+i8A0z0EySL%mV!M6BLx@L^Izy34 zrndJ5ftL88<~#M7;HC3u|FokosdGlqZ-lA7CBB$dT@!y6IL!I9NzmQJ$s3s9n&}u5 zGw#K%NC3L>-2lR6sCJz?E?gBp?_@ChwK7+syB=Ln44^$@Hqtv-thaK78iES4MeU*t z8lB^^uU3`kUoWj?)O6brw$`9Su~q0p^YUkKO;^?tA^W$}y)mOru~&=A3iWNd?WY!p z)!!<=)1H64;NKsyo@c}gYijvG0LN9ofa&LhWMr+Wtc0z`9mekKU*CpzCrr8{XV50P z4muyqg89Q@9rHekB$%uN*83c{LaC%~*#UX6#M_Rbf}c(CV~B8%Qy1$0BI+$!+qg`W z8yg#D^8o84wAFX6VKWA2VlU3c*>|t@c;dxM+2c=(UGEZ29;q&G_PO>xOalWy)hNW3 zv&|ny3N!v}?9Az|*WHO+yHg!oSLn2$e%NlrsRF2qT~_u>9alL}1>A%!+fj1hfyS~6 zWpidgLGWU(Zu_6y(DrKT>+Ni%OY$b}fdlSDdJQrtx)5dh+cAN4J8~Uxo;7x)6 z31nNxq$+D3OtCyqRu>VtKGPbr>3CV##FFz2=#!46<~h#lnwd?;xvVqf&K9ED4R~5_cmdX5OC)F<7rCWQ+V1q0^XI=U>DFl%OCFxo` zhA;SZW&fNJ@_!}0MBy@Wn@xn1apd+>KT*VHH(*5gbmN^CJEb6|FuRmQapwqIS%st2 zC>)1lJx>yF0899jnrQP43=?~0jn>6Dz&D4}27wM&Y(}5gJ&tbjkC%3F?6#v*Au9aq ze=O}-Ib@^paS<(P$}hSWZhgzU?AF>#{CLhAU1!QG|5YuwL3ehxWt0AEbhnQ!sbSxC z-5VwNv5(Wl6KeO%l|C%$zsgz&{e>sTD^fzDSdCN)Ap(c3uv;rE zuh6SVs?chU^*w(DkOvhBH+PLqOI%Q24JC^rJpKgppX#@f@<~6sa}VVR#rPf#ml@rk z8Lc+$R?i~UEBA}%Lbr!}sC=O+eLtzL@CuQUxLtv=j0_VCTcIEp?W#2#x^M#%{T6%k zxqj7ze^1f4UgJ4>)m~A4pJ}vGZ1&}rnQI7XpijGCJ1;jjz*y6ON*%~yncts-rb7}f zO7kkCwIIrwo@da-A1bLbTC{@#1?DT>>>#PmfEAaDS21^F+1;A(qO_{4Ryf24=&+JE z5AHAJ6#avzjruDTlVau2*smZR=Rz@=5mfPo(0R_z(Are#qvbhAXsq(Y}Oo-j*D5Z;4z)G z?w=K~|FRh1n%^DGT;FNSO@7`8d8<}T_JNX&cjfxuzaX7mDm^ZU_mw~^tqF?_d%__P z^;(r2LCM&8r=;Zh`cJtP%7mz1E*Be;CMgCjp5?x$O~5mA9Eb@JMH#`CN{1jOeb89H z7x+5%Rs*Ig4RG>S{o?tRs!y8T$VE26>Ges4Ygc@&Bj&1Ouk#%kt(45a!M>e60I6ZG zz>k+e8V255E#eC>Z?FViEhqmh%E# ziSfTm#!_=YU%I7^-q5+0U8SK+_k+aKYaQFH7WoITnY&kjFvG@N2PeHK0 zsmy7ExYK3Np54u!Jpn%F=GwumNt-l0`9`K4}}?)W}W;Pd*h8Yn}mJ zyFF|oxpFc~1>$K_}WS>(s-9AZy{4OpY5EoLLz@682FcAAP&VTv1e5l4MYP@ zDrL%)?!gDTxTZ~Rag!!bbdNszxD<_9?2<@>o0__oEn2%0I(kER;Alg%=B&_|BuQ{o zZrHGq9sNK3Y=q_8yJw3VH0T}IzFkLgg(b9@i@L>2u$8{djr{yGaqU%H>sD=D$*W4a zg^L!rnLp2PZ@vAh<;|75pnZNTtCME8v~;s)&Tw<*&UT9zFLI;580{W@_+ER@ALPjf zv0ZoHeye=VTDl2ePjK_*&C%thh06DZ>n6K*pm5BrWB;{L^}MGKweYuF*f2@Pk~<# z$|R8uEFkv0;}qqAzQ&Wj0Dzz;R(p>e+UH;E^B0#cLk<&Z?cTl9&H8y-(6*gBEe&m_ zz5e8sJ$p_QqpVpQC?l~{IV`0zfOQjH_4v)C_=Ie(9W)v1O6ihYeJHNf+AO zc!p&Y?E~VApmTqKvLg_|z<(73aY)2LQ9uAZ*T-+{d0D{Ov*mENcD&sd-6N0OuS+gb zB2-VaDaJ}aOn8oefxyQ1UysD%Th*s6{1fF60_Fy2agnXJ-?a3>fg1%d1O9_uhNEmOjrJW++L2Anq`4hpwe+~n2OvD5H zu#o&Wk1^zxiS*wMGAn#U4*%tCt}~!)vbG@IrVDOrb!HEYKiq{+xCF#?A5EgtWcD%r1F&u^3|zb zS4zEKLgEi{1UT{=!IH&uP4-()O>oLSKxyhvCg*K0FQI zMFR~ieem`7e=ReAg2XT|x znUt_VLT$=Nj~un{++L*uj~jlVOl{Ppp zH<}_%wi>z(Sh>pe#_hfS#w(^kt6Ht9D^=ziSE)*6{m$){EnDjH=gTj-drKD`y}}I} z`k5O$W|GUAHLsex$4#6(+1+|;E6Gs5v9^^fcde{Js+o!1^SxiT2JDhZ0J$y7&E4XR z5zzpiQ}pexRkONe@;|yhF9f2QUAuS1{O0((cQ#zf#Z_;YHOf|rH`1F) zBv<%Ti#rFB%jDDFXyx}ZDRv>uSd;(<_8$l~Bz-3>;EDLgjT`JlRlQm@cWD1s+3+Wq zsN^@dE_#{7aw}v7wARx9@vO1ekv|a({3kIGheVtu6c7d!eGn+*llIB?&_4)Y`*ydw z+`00EWZbaSREPTcCRl4?_#|Rvzi?6=gM+762h{1lzRFAskNdtK~ zvTe-bYSyT2rQi;rN|mZ|vh|q#-oJO7OP4XH6n=Tl(qiq}wK8MdE0rZfq=5 zzm=Aj%?JB)#4G?rkN$8|6M%r_m+4}|hK*f=22Dj|R@n0g9(Yt{e<@<}@epnkQA0La zyLi~NX_K8Cs%m4(pTD?nLQZr|bY;Fx>vq~6b(=FxwhPy3n~0%ZI1Ckn_yin*Z{VO= zmf)ByAdDMjC(l)UzX6fPr#%wdP%!TUwuep@lxeqUeIJ_?PCSWB zJRfyY8lTy-f0RF*%98W9vQrWqIWTnS`(~kY)s-dPbI-mc8~9b`%AiM&-ZIB5W|l#o zh<`rvGmXt*cl&L3X)|smd;7(*OW*45y7PXs^MCxYN3Bdh9=<xlGvXL(m$xAs(W1|cQ?*FmyS;P=h-b2V(sQx{l5H^nF0M-V-m+1yx`J6lAx;@<0 zL^5;c&a(6wqcVtKC(<=~afLdXCzW%lp~HsB8fLG|*Q&Ur$)jAy+d4{IGTTHWOP8#3 zGk#j=W={Xfi1@g1W8AoLBc;TgcW41o?K0D}VRBx0Naw5|o}0z^+{%v2?X--{f9=v6pMDfi>F>2ChKmF~k~A6ptMl~6VY5YN5xYCmlh+jSDU({X`k z$>M0&N@kSExIIB8?XY1VxB&y-H|6&$FTXCO=@K_*_EK}QhWvZMg85b?_V%5u}wrywOpo~hDDwPq_O`A5$)C_X z?{O8%SJD;l9kMr>8jsQ!*KoSXCH~kmlm=AMwUNnn{Wsq za_aZ;i`G_7M&rI5>lQ7VXBIspMhsEjb#|&^b49;_*r`E-8gAjjxt2dij$E?7IqJTj zIz`q<3)LRn>YiCNWEmoVA{e;H7=U&`lkj1LrPyAX-+E@~Y^%O3bCx`3L4t$gsPM3k z)H!qHkk}xZh|esE0g@O|JlhH}`#N;^s4OjZ=tgE;Ti6iZZ6eMsnzb;SU${y*#`PQ4 zyFPt-%dg1+<6hy!=gysHgf~mpJm%lSbClurL+o*+$CkhxSOEYK5rG6Zdhr?7Ngv(CP zV!tVW&))s&D<_Twc2ztk;bqCh$!4&D-nnCA%wxV__m0^lfSozAW$&AW-;5h=P9`7a zu{cSp3sq?hAKSKWR?ZWapFiO0n0$$ZZ;s#!$5Wf-g)$s+<}4uMxjg0(?sgB$-7tM% zZ}n~E@)mA*;ev}7D{A)oF`4w`1?dT}PGYmf@+T@PhZNDd+?@GK+%d`0!+|UHczA5o z_$G;CrdxZtnR_PRDY){*u@;;5)-BO7#RT&q;x8ozA(1ct2nO)ibKt;XF|}74BN)AI zoz_`mW1P;mL2*vRjjPiF$6pD)NKS`-{d9f0Sxa%N8(ha*+sT%C#wke>CrxLrUhyLm zKYpwS!tg{84{U$&ddKm3pDxMZ$2?v<yrWYB1dmMy2U)mUWwYQn#2$jUSHzj$h>KFCRK7$K3!Nb!X}}TX z;fc_itKYZ;U~{8BJo}mKm^xJtW`u;h18}QLe!Toq#;#p~nI+$?Zk5R?fOHTx5JG>k z!-u;j4Y(zJIPepkVy_OtM7hbvuxHPHYflnA->c1!i$dX^0r3_{9kgk+{F^-Zz$316 z=lk6^6TWgEeLP&7`5||G)f-%g4!7A!tK+S0-OO2Y++p4B{Bqp4y6}@jHu`Var62SS z9Xi}IPxsu@#Z398O!-Dvql+66hPXjiARNa;%uXdSzSIl* z8B53lWnk>@pdQ3FSabO25UR5QQqlgi$@fQT{^IC*+L?n zEVjr`dYP+B$PrafnOBxFdvP`r`|s~&N^UOeAa-H{ zL(V^c{#ZX}vxAw(d8f&U@~E-4}&J^)0JL4L{t zJ(CC|JZ?|7BOEiC#9L@TF4Jl)RC?{-O~g%Z{D4ia;rpYZa2pB{hh{u+KD zy76x!pAiiFw=i%ah`6`s`d9Uwzy@&TbTGn!|Eq@Pyc@$fGM;GSQ844v1@VZ9pfuJ8 zCA&sWk4TMY=sO_bD0m5$!F+(wg^=SVFP3`8&x!N5hq zK!k{26tqSPjbI>xfd~fT1_Kcy9yfT7)Dpo!1OpKaToeq%IT1%OW#(e`d2W5etjtW( z&Mn*DO3f|6xUTbWH8_&_UyXskJD2{=irs&*Z2?{RR}cL~Yh0WY@z|c6*&-nz@V|e@ zYflt$xP+&@6bcl8qo&}}M&O47m#Mr9_sT&&VrFjA zHSEos)>!zhb0VsmTj$CpP(Bw;xF*i^Z@lmQQJsHQ_Mg2wi}5+DB!9b(Kf%wRmHlV$ z{;c7Dmhbd;r~mfl{?T*Z|JggA|Ia@EZvHcsjhQ4i?>K$iwL9=s>F3j5)~E4dJ>Wlx zIe9w#QT{)Aezy6!UG+z0gg>Vr+_F8rZI(u580Zt`3q~w(pcdQ>@NEe%&p-2@{vDr` z0V9`84%euc-znv)0}q>ABuk@PzN}TA`+UBfOZ79(?8HGh#5vX#YAi~#X3G=uGI+V7 z->kD2QBgVU>ODWmha1tROUZ!q_)8he9McwG=9$Vn z{oU!meYyTQzr)L6l&R^j+6B78aTEUg56cc4C+a1guiMZc8U+80G~s}IUR>ffZgjUi zGR}5$=Phy>Ho?Sr_rmGZXH&ow1+*6YQG0oJdbvD19sVr;U(E~p zN`Bhr-TY>dzx@;H6Zj3UpRy8a9dF#Q%9>#5w0?aVpzn0t`El{(kcKi~=74nRv)V~H z+$Hq>eOu)rG>v@TCp%@WT82yXPn-~OoFC!t6=V+?@~JDLDDnhA=K0UFX1a$TzSk8f zP)y;t7FvwU%N0?kS+kq$x;4Qda51`l+j@7~t#{}u_8tcjPn9aAZu0H2NrcN*+`S%@ z8(28`lnNhq@X%pKb@=ds+T~MOu!G&Y9?rx?%mT3MThIi#prsuEZKfae5e|y0;jbsHr7!6dXwxC@A*I9R>hJp&Pq!p- zhZ}oUoqkg%5pI5uAGw?jaW`yehlSe%2M??4q_%K`QOk3=+Pz=xCs+uJkUrugC7B`v zA&O!(6Ge%(F$XLf6RT0r84LPIKXAU0C>I|) z_+&{Ia>q+K@wM0w$<6KI!+{H8#sq&WaB$#?y6JPuP}sQLy5gTAIj)NnD}3K^rAcW1 z>ghMG08%J?9--{0*G`d2OPdJF3};B+k0^Kv_-9Pj4Z{PcPU={@Zj*e&oOC|-snB$&*{uKqwxXRV2QBk1>bJ;u% z+N(B&#vfcTH^4h>|+PKD^!x2D$H-tEZ%vo~Vg!h?P z=uAccf8%T>DN{woa2`ILeJXH6It6`5q78z{6%KgkTNl02f+Sd=g48Ku{HbTjn%hny zKBz%dR1UdUg;<33O+h8_)$RL+hezD@hC}>>sa%#!85FT+g*`q~-@Mm978V;#IDW~* zsfY#Txt;Lx33hQNAMgaFU>qPuj5UPIb6h@uzI;U#25zzcX`j>u0Xb8drzF%5h!)_- z$$s!hIM1SB8JzHbAI9Sf1&oL?Uo9lX7rOV1NO=$-kE>j{3jS`)l*47o8YS_^zEE(B z_-aQsw`I#tw`2PT3&wH!$&%n7;221(&1TEyZ84r3iHXX5^1w#Bbm@Hm(?0PjvoaA5A-ITqcB@4=j1t8cN9i7tV{QPcP<@`+d=gxM2+LN&^e9gRfq_%DvS;h!kI|W_|7AZ@AlUzeoP0 zUlq5TCWdH2oO|`^t1xTVS=cb53Bkbw?C@|6JbVAWVeY#rlf~t~@6zh1`Sx3b+^pF@ zyLaDx-FE33)oU52GyR8Y?x~)STK>+RA9A^J<#rD|aF_jl^wFN;dQ-bDT{|10w}+uW z)CJ@bC-v$puPHp-K$lkG%u1EI+WK>KsnTxqmQAilk0;Id`FaIT?%45e*Pwnw<636V zp5-2SmR_0*F_l!CtHQQz*gc0ck+4{>*C zU6(HEn>+v#U;++5T7jDHzWV`Jr*=KzC%&6DZHjyP>Bo&Qmb$u}d+`2;6pk)RQJ1#4 zmtXFyg*r`fM7v9mkxm?WGKGNC>1o+gcl#Y}6f!QWac%^urvHyW{*1W%R4$(aO0QY7 z+TE|V+)pfJ*R$sf7Rf1d<}5CoVtr2jc9MwOV#U*JE6zBH1(10D z`JR@xN|hQ~w0Bz@Hg4MJo_+2awPAq*B)4=GDpb-&@{21`{7P$c&!?YqOP9fzp$`*RjSlaK z%b>7?guV+W;t=rXd%x_izPgM!)dTK>0q?pmzW7vYW1yQoXO_)h8u{|eZ@E<~SGw&A z(`RAy^lXC&aIxZ7yKY^(8A02)af7>~Q)kl|k>`y!-gkux7PjEqeP8NhV@EVBI9X0= z5b*1+yFugM*`-gHUh{Xed*+#*3TL>>Hag~S!-kFQMECQ|8Sc@?9;8@zqXL6>Xx~xm zF1f~NiR;w4qdmtbJh5>B@Jw*)(q+oJV_Ku5Mt^R7dGW>9Eyy(+{fm8GcMo;xJ0f#(KNbrOQhGp3uGA?=6K= z9H!W`Z@IpGpS8N{)Tw9dnjfrNHtKMkTcS(sPe7gGqEoQs0+34JT%dgU%5J>kQ4$5d ze7OqttV)&ZUDfMvummJM9MWxrmiFSnw6>*JjZen>F zHmI-Nxtd~Xu9Wztle_cI&ThlT^%gAmmRnl5m-@b8=}MHi(v`cmynW|S$*W2!>Uuf* zO)N+cw3m$sc>T4vj4+M=a-?Ef7I00QG6DYrHV{9bAlB8Y)l?kRuZ?l({&)|vD=2ji3scDV^5v^Y1W{WqdUx5F)~s2Z z$AaH9AKz3^j4X=HI$rTx3%K!P$J(0_AAg~s8&_-78Eu<)@8|m1bCw_?3okNx;rYH2 zyQEOu)}b!1{I$QN`Lt{2CU?_K!B}nFutCIshV_q#Tfh_Ddq~`I%-YQS{p6FN&hlck zYK7X?TXU$6a6CoK2XnRC!;fifnrlN{EGB24Hmv<_`HH2+MR)D;h+GZtQOw%Wu99&2 z&O7f}J>aZ)^Om;3{4o7{cTL%A#pDfgKWolWCS|a$XUzCMhJcqZUsa+1N;uL3h*JD1 z+8$h@f8vG{H{=DJIHVjI7jP&f^E;-64o3|T6xr{b5OfG+=X-9mcSXhd5m_Ce9B)!I zY0@`ZLoM75Vifw}$mHujF4xtfCU0&oaSMi0jou-X46B%9budqrCy zi+O^_f zizvA7Rkk@0o~&NIn_Y2jD%-{J5+FIR#6Vt_H&F}xCUHaneS>5AK5`Q$Om>YMx6mXf%v^ZijvFy0Az~C{xWLT~8ynHQ^|n^_oA7K9EZ~C=-qj+U zsxW59T+t#$jo9$~SIMV0Z6b7DOMZ%Y6m*5Pi2@wedGBFu^lJ4xz|XjGldXSu-PPV+ zv(az@=-00|^hkt$k)2j}Ipe2=hCfb4!2EeD#0{5Jkm*lc|F>S(*nVg7`7+}4N|h{a zX((j(?p^M|2k#f>zf{2^7K$5uL7PJ}_t|H|MYM}J#8}9r216_r3*SVvr6X??AapUd_70zM5XNcH#?tHHTLpO27brN~A z|7-5mSNj+q`@Z;!xaT|F`ycez27yRytL9s2OEYcS5AKl4EG43UZHTFf(p)Bt-5i|; z-qPuqPd2(@#jZ9b|H@S>t<4iAPH?qr*0TDK96oF~;RhQb#PjK=+4v7=L+)mb8}-+! zRY&XFi8Fu0)veo5C*vnf(YtH!0b@||@!+47}21$GfP9t4zO?Oy5-heSL;CWC__4e*4Ui1Bb-07yrX1)v;( zAjbgNC_n%~-n(zFz9n{phYr!?z1g*EcZb9U+1-3`l>l7fS{>zqB*`*J0khmy7v}>I zBhrgW_5yGJ@UbN8CaOB^n<2z8UTxBdv*sW2GU(2r4wA^ zwD0GrZI{{DBVGWoDnXp!;TLhThmQnGy*zme*=FZ;DsWoJKq1)aC8F?sVXm-QrcRyO z7Vy$#%j_K-wTJ9kGl{$3;iA{A)yb=*&9D99=;qA+(cN{&-P)vgSW>v(AAb1O>d2NY zyPiqet^T0s0~mYoYYIs{BeWb)v}j3{y-|9R)b=~O^3spZn>S0$msQ+u9-CL_c8ZrM zW97lk{y2SJ%=1J#;jl?REbjHrJMJ}3f91*mPt@-j7i7Y4wZPS-%h@_%Be_*#5O9Iq zxS9U3ULbPJX@EJJGiPSEVE)gpg*aVKM|<|{)`>2y@F$sh!Zg|_UvbQ%+cf?iJKmx7 zG*_`rTUdTJ7{nhnbvi&yc1Nc`90@-B{BPZ+wfppwPu;w^vqjjCm>6ov%i633D2)9a z89VlKH)!C;?x6=Cmfm5f%bqQVi3LeVANGgM2^a(~l4V^ZPVwmwyABvITqm@o7I)S3 z5>lpzx@_q#0Dh8&PC!$pOfYjwHbTav*)1*Izya^O>NT!&!-fqO(K_{HVg=|A z1laf8YKW=E-uPrj^p-4H_q5e7Y@fl;0K)S@xUg=x@y13v zwbXZgUhM5^)~Y1pFiML(wq5jl_Ut*P7)&Nk5~8+_#)H?#?2_D=-oXga_fVHj4jw3YPg)i%sj$#Ne6efCMmO{4 zSxO&RY%G|+)cQPl$Ut{(xhq|pwyh-N&f`A$bcE$yxM03*-pt?0lPB6U#>?ZHO%KZy zVAjvmHUF2myY9TtOb?mMJ)iC&&boxFf76Zb%B%7VS7S9NGb_I{rDe;O9o$U~ZZ=Dq z8Z|4rkt07d!g=!eZi!Qxy3h2xWXXc=g+9;P3G<_mMysayZn@^-)uoHLZjW}+CYs)T zHsVuj1B=18tzEmeu2JKL0hQ}HS%^3b{H2$k6L31sWBv$C@|uak4${P<}+a6+xCpH^LQEl(QvizH#6Dgg!joO zV_e&|9VG_qZyVPGQc4$<_@hnhR^p1sneJqdtZX<10h|UneHPLw6B#(V0fJBih{p!J zKiG@{MvNG0n;&Ca>guxY1?ePuKiAJq_-3qddcdq?$b%`P*N4=rd!sg%B__{bF7XCy z*5iopS+Zm$i_LGw_Le3MrMtS%#5wd4pgcdX_#K~l7Mf4AJt|`+DNe78*_bgkMF|cZ z(@E2>Uwd$g{|T*>i`PqbO(r&qNFaLM8WE(z(mvm7gF9)`1Sz=gG%Y@6Nr+WEA!y&e zgZo&r-UmcDx9CWUHXOMzV3Sbg?iYuaD|Z3cU)C;n==ir-+#Z0e3~LNzdvNFg`I|St zNdzvJtQwxwBrk4CvmMg^)+b*sC2C};+pM2Q3y2g!fk&OBlS&Xa`XVTd0XV*!g$T2gMIPaZ1)?4~nvuB1* zAWkO<*(b{sj7f;iaPHg%O|yUZU7fA{WXO>tt2p{e#(A`ql5zO3;dX58`Sg=k-it5x zGDU6scDI^!g7r;`F#uWn9@$7E8-3!5hmG)|(1b%=uyCFhQ#(udiHHaawQI_jQ~RTh zfM$}J8sif`jNopQQaYS4c)V?sk-cA#MT%TyIKf69yL)8uh#+SEJk#phvSo{jZS05s zY~H*{reOgG4e>zXiHsk)^0;wh#F5|Rs#Lk&PAG^00O~@#fXHUpkYR?;7yAZBc;1~n zdDOU6?EjI_Av--TVlZ{;1QUbIo%56H`_c;%TixnL4FA~b>-~H$ajna&{0~1IEE#um zDc9aJD-?(m=BjL%9v=`~AlTr84XpR`&&oFZAt^ZDwtg&Hw9qzN(sk;5x9j`jORl$M z_vndG{Ed@b9COt1<42nUc##4_d*D>DN%D7K`Lg-$rG9;kK^Xn{NE4Yn*ZXOE_vmBY z+@p^^?%q{fY0C_8sY{p4GYcr}#W7F)_~XIGxFB;!X}fy$N;|bys8H4QdiDvW&8pK_ z3sY+DQoS$qdEU*Q^@9Rhe`vDn=@J1U2Y<0Ia&P3<+fAIo_a}RFcRhRdk_AdX&5d|= z`g}rb0vI*wb9djp4{BY1A+zEeO=ob|ot_o^{7O@W8sq&K>w-^hnW=g6{E0 zyUW_Eo;|B5W(`IBE3dp@_Z!%-)%E`ixGZy)JZIh2fXmP%kZcw)$IO|t%v=eM;IDwm zgk~H&>(c>yVQiow2L8Rb*WR~xi%jTph~TY@$(BAtc9WA*W`^`>MNk)sIJebe>FPS( z)=ornrroY&LU_C2wCS=-^S<7tNt?ym!4Y@g-mNZO29>@2_hnNVoPfV22WLfn{KlMa z*G}Z1hwM8?XrGR@aq(os`c>B6ELro4XsnXhBE2@0#I`~CwA?y{So;X@*aaVv7zI3V zBQLEk#~|;!<(Afx8+LPd-hG?QIE4*b(v`V6^?AA`- z7uw`6I)&qnJ|5L27A}c+8{>TWiIm{fvOIR33$n18>zZzumGrT08DEez!DCu3~J z3rXp`bjjQxzuL#i#gCC6PhNu0n6|3zYzm$MK}5tQCU4iCQ)Y*LLr9c3txQyPnQZ%R zck;wur!^?kK>B>#B~Ovj=3V%l0=Ja6LzYiK+H_eomy(z%C%+47{FjBsKTjUrjaa>e z$C7Io)kYUEH5phlAx4U^$6CktNi$uk1Dywb(rc}^U<^>WBGLm)DJt_(AiU1-`vaVG z{2ha=+44!j%i20_>yA3T{dYFqIpE&Kp#wWyq9hsJvBLp#M%ge~o8|5TNGFqFh_|0- z;dP-z=F%1y(0pIQZ;V<2|MEY~%L4}v>jZqYogAaZ@Nr3rF*;j_Ga^odZ1QL!aifie z!RAK8#ZpyaWIuxRUjK>LId15!zLN~Q_RLDe>>4( z{eUF`w>en|silNAlYZ6REwV}qhEw43%rfKg8F+jmIZMrJD;IUS1}V!|hB$!iX_ z#QSqe#NYN~ARz#l@71eY-yz4nF9jOE>dAS zN89O3FkT`6m+35`{GNar-ZYMSY_1b{&Xfcv+{oLpV~@lJQu>+f|EOJxAx}7dICj$x z@`5kqyo?R)W${yKTD4ILWJA4JVy;JJbKK6gZP&_fLt^2>u^Ix7{sN-6LH5gy8@IGu zli;ml)oQ}k<+f=d0!bCRy}54PdY3b2b`w`{(xa|ay1~ee%-!;Pl0ADSvu;9n;pazq z|CNDeX*>c#P9HXI+-CEDKG1LGFu3aBYiIxfKmbWZK~#hQMX%ee_FZ8LQBFzVf;>4 zS)0}`-5a26*20MsC#}ESh~2aAU@$kezBg~#F8xkY(SxLR`UFP_69((auP+z_$_IDc z%mpvI_v}|+lf>YX{xOgLOYol~;vkoS2qx2ShI}ToKPCn5gNf$3dnTJ-#5^OvxBZ0U z-Mne7E^a)giIZ5D6&|-sGG6JStdLm(orWJ3hz#E2CulzeJ0Q?(QbFHN?Pqv;-*!F&yitB^pTHHHG!`yi-cR3yGEfu- zY57h2JwBKpyyG{Yi4q0Q$RIdeF504F_chmCBh$JP?u#*FlqT5Zk82aKcBohgCfr@E zTERVnk3SwRo8uo$MhOv>r~!LwH@m_{X-F@%MH4{zFWe*PIZ-%k|}xj(J9VSTo4# zc^2R~n7`!ngb`dY=a>t~=KY-W?c&q&s{+aZ=X{bb7~|kM_0WDlmxJ|vQf2Gvy~^dp z;LG%V3FcF%e3eNXNW(@C1nEzmZ}#F}{)c}3r#SHojZX#`&N%jnmRGXDz>fAp*A>_0 zK{Vj@*`xr(0&qYqxCq2z03yMH-o6bJ)f09V9&Qza7Ho|{TILEwZR?ik;DNhb;+b5g z@QfcnAgrMmk&g%l;tT^9f{0&q#pcDt23!rtt6Wn4v4iiFnBh`^c=2TAAo0qj*tZKg zMxGnX7=_P`BqlC{u!j?H>SL~OB0{d8Qer4X060{N5bz5*8j-U82@L$Fi8x#-$2621 z01Ntj!502`3#HsJn$5G z<`x9_3}mK9w(qnHXMwmPyyXAC?|#Of32SFFUrry3|33`=^F59gz+L3FKp#2M;z|@N z4ZfcT&9qjsXu}Ei>A%mlD;#N1Fa>hAv(L>0Mgatuw^xdq@cgvNySFt0clO1elJZ>o zMm^vbnQpMSVvFK8{;Y8?`*(03Q=-O`GC2XEY)mKR6*mQ%LbRY$YspZNkNQ~ z7()X9+@NbcpWfK1tuy`Q`m;eH~WJ0z}Rud?^P#(zEy}8%O(af>+6)cd?jT<}J-F#CWvn9sq6bqXEuqb`mlm}Pm+e;hy4F^OX+VAuEzCjdd z7u*u%@t%L%wykp?4*OhIIxp%{&N}&`$YNYM#1w_zbGm7V4;F00;lSu8xTSAw03J8A z&DV+P81)33Wq`Yr+Q4wwzjezFvm1X>?p+5A_(-=}-%y>Ijlg04#zsqjz{#1$hB0N_ z=mYbE`aK@7n6bWT!=~SiJ9h}~y7O+iYn`Bt_o!Jt_&$)9aSHDP{qR6KtbHDPtfwv; zHM9BS9f6UDaey3t}&&h3~vqP`$b-x<7&6f@zBQUpEG_w z*aeIS^PaKXv2&kUKE0%iYcI<~Aps`{*g#uZo4BHVR_diXzWcdtX+wEg?|yB2{PUiF;4xS?ff&=T8_OT6 zi}9olcAC(dxm4kzbK)#Y<6g87uphu>ELV}=)0Jcv7C{Lv6ZiJpuYm(3F8*M0lS;PR z5Ni)yG=PhNlX~yHq2{KPn~4Pq7T3+OBjyi+v{xiaCtFt8hRZHCeY(_kp<>J7wN?xl z85mSf0I3y}fk;A@M;B-E=FO)C8caea2$LOCCnnFjXgCfz5facoF4_bE9FmyF!f(DA zFJI6f*kuTBHkDLnfn1@+M>)WRTB2KX9=McqP?2(W?vkDEFZ;}|1rw1QkTUAiwfr2m zc#|Ye=MEp*Y1s%nl`d^cDdi5!Z$)auoiERO-^-aZmx!Be)ddR{%#sU(H1bUo+-k%N zq`y_Tdd+&9=CVGPzv6 zR#(TX%6Zd+ZsNo*O)1OR_`nm%l4mf{3%1Zku(T0`2Or=xvwg>IjZJb>P;Lob*ulwJ zg3$GPyw~N)oyQ&H6qhuS#w?jdmI>OKNMn%Q@wa@R4AI-Di$?hK z;XcBUp;v3Of6~?iUIPJt8H@aw0bX(y&ZD$58$b2H6%U7(_glb`qrU>MaQv?ow z*A*+?AZJX~B!Y>SABz5VgYRbfB`#a$8gT>Z<%|3)%Y!Hc-wOmQ=-;2~&8u`}vx8zCsJOuxr_(g)Tnq zlC%An9E)7Ms$AVZZX7{$bhPE?LPc6}i~t-HUg4PZ00g|b{FvZ>dd7^O%)bk_M~fED zX9VHLpMJ85Sf)%F^LsLC)Tget;(N}THP1C`+Ssm>4<0&H8_F_sRf`CNE7H}f)sRSM zl^ZnZU8@HIj%U7lb#Jzy74N?PzVNrw;t@XkTra!s9xp*kSF6SiZqj$t?PSE})%%54 zbpGEbB9Y$+IBrx67cT6+`f9v5@ub>pcFF_eDiOY<77>UI@ZEQZ*h0o}lZR>F&$esT z5OFSka3bI~C4M!Y)k)ysft7ZxoLi-EbGO~r+4?(j)F_>3X4qvW!hI!5lEKCeU-P)c z&7WTHf(T*-}S6nv$`+8{8D2#RfHnD0)q^) z%PGWpqz$=sQ?f<#hs2=lv#Z}*x5T$i!@GCAP8-O~nX`3CXt(C-y*BU0kN;9_m?~d5 zQ5wtZjqt+JUtjeG3krdsoig$dQoh1<#tc-iUQcc%mWY4{_R?_lTwqc#KwYzcN(B0- z`S{^>Yof%d%#j(F-b$CgTJumoSO&apeq>^9Zfe6AH|`7fc=s+ge4mUMBX6J$jfj8x z=_fix;q=Ox{~U;`_eoT(dR;LVheeE!iC`bJwpY_S#n;iuksq0O1%E7f*_=N8XO6wD za^)JvB#jv}N;h3I+q!^|A2@hKj^&yQ-|OA%xeLY6{GxW(5oeyr#+&+y3y@iUjz*6j zZ5O$sGUafY^XC%nsIKvtsng(A-PA2D{iXSxf}K;yPvmz-s0HNz0O$YsV+&qE<;18og{hQ6ne8Yyd5Ttm5!EvJR|phQa@`z!k=?3`MJ35whju{HCukCpU|ezQlgse@+H#1T?F2gY?6(a9KkL_5uWg%K zznA(uf)8xjx>=5ys*3whC*K**nfubD(i`}TI>buFNk34s2SpIj!vfz;E_^oSt_BR~I2w;!`>eD<0HrU3)qGe>AdoWbt~+%cO6W4LnV3R~2O zhQKR&1~!3V5+4!4hJN5v1+RM_eKgoM3J5twagWLea7s3K$Uvp5FTWtoL_nt)C%bvm zF%!|?-j_}AW}OyaeYKxClNvhYQwur*alW}>6E|YSaCr!POwPi387E%3N*VL|*G0h% ze$o74!@KFGMj~2eG(Ihi*?>7{tsofRPo3bt`TBdEG;}*sa^mW^!_NuDgRtL6suDh<3`8UD+FSlI3{Bo4* z+vjEX`N&U&|82I3_k8LZ8X4PxW}leBF#0^QoQ2 zwrt&@ylE|%2oBKmgAc!VTU)}TQHTtB(BZQiW81s%El z&Ra#q*J}PWF!#8`-zD-BZkv(qj~O%Ch&bXuE*-HhFExl0nfR$l6B0vsWYQCE3I|Zv z-%w6Yf5yozXL(DGn@o1{s@Gqu&|aO5kZ{|sag(}|*=;fpd=T(g6uOE@Ub;*X5!F(f z6gw5Irn8lSi&tXrQAXLa#VqVrqnjJa+hGX+&pL_Lo@85lAh(e}Kbp79aRou@JweMbCkAKF*2f;OMs%IP zB+g)RQu=}0&uP=XkMU~`p<*NOlM8uj;2jaS0?CYns#UwPD^<3Fabt}d1y1r?wQM8b z+*4h(>J?P?a~8b^;zv1$4<9nup0#S%GW@m@2MQo}^j`2%q(~_)XumD8hq~Oa%S72- z$M!9hDyj0;6-V`{`h2Z6lh2HU^6i5QL<|J5nR;K^_=IoPtgSZJT;e!q+WX$Uo)b|^ zVlGiDRVuBM%ua=O>t^rz%2h)C0tH;V4sC4g=ggjE&d7Lq?>+bF?{DVvv}Vmp;^+?> zE-a(|&=v@Iw{B0mV)BewvQ$A=NkkKoPKOS+Su(FwMU1s=>v{_-$A;Rk-*e_jjA%!| zvZeF2Nj#+B-Njs0oocSDc(typuXLraF0BnXtvvBvXO70IX#DZ|c$NH+1I!WcZ4)n# zc-X6`K3-kAu$umxn3IPkH*ahH<8ihHV}iF#TnIB)neQ9qSA?if0G=Ch5Z3?g*R2iF zYuB2yFv9xHn*FoHHEWH4bHe15$b1DJe6Xur0qnH8aXkyZyLazl@Ayu;YSyl4VdJS@^XlI*T z-*1#(4_K(-4f z=3STO&70o_4}Zt(4fr@l`2xUka?Bh%c9QMNa4LB5t5>g{Ih*lu8*zn8bn(DNXy9HI z**lvCKDzPT7Qk=vKRs7R`#=^-d`t*+nlypj8_`MlM1cc?ARj-n-StrXOX4BI)n}9} z(I08a!{!m2kE`W@2!`j276%TDem$|%+6+g5Y!|uxQ2F@=KKyW?IMD-II1|MY^|B~A zJm0!yjpgBT3xHzFZ&U<%m^*j2JzuzZsU5{p^tG4o5#IIb+t=Q~X(MX##Gmo;ZKZ#- z1E^BDsu6!=t2=gVHzJiwr==f%oM!L&Gj8k`@@*4ru(%Cgwsfg+_c+sf>+Ltq6>0Sv zHyU?Gj7bmMwr|x2gp4x)(QtzzlJT=}KN>#D+QG>KLI`k`o_XTiT3PMeC~t@W#Gq2e zDiW`Ba6^Z_?|z!`qj?AX`pdCK;9q_1Wr=Vmh)|}n-^4M!;reRg)hK%hL~MM=j7wR+WBiPef2bMwwS{Vg4TrmHQuUk2!Vbo3gX*49aPR7{=* z30@MbD*!+1)<;Wrzsyb`<;zvj_;-{DEpWr@=OOx=1`TQ`;?e?HVdYj-r*>98vUmFP z!3S^I?_nPfHSdz`+TLzr9emleXx7a95^>rZHf(?^RJephY&)#~SO;ApXJAi0*;C=@ z8e5#*qD9qC&7XBTW#DU^xSKe%%al2%A`!i=6Kr|4rK#ri0qvDq=YBG1V*2(n$CoM) z3y?8mI^lJzPRb&6A-ZEPE}=ic?D~f~2t9Vo%VMmR(y zD4V*N6ex^flt5WEYBAeKIcXDv$4 zO<9P%AgKtUh;it9_y>4kgIO=)jIx{n4}_IN*TC@{Me&LiE95i1jLVfPmrffwbPAed z+#JNds>D{n&kCwgQ5-mP2UoVlQhnlyUfTF~O9WR%Vw^ELjihi-h}iUhbAaVBF;g-dtO%JF2RCSQ<;<$5hb8V? zZTcMoYZDLyVBYzS5=VA6N3_;hugnShju9}K^sxzpQ~x7RsJ9P^A8YlhB>s7 z{YPjqFi0$JywYJthW5X;B3zd(TiWHyncG#bUfocOgLs0`0>}{g-L>mIu0zKbvJ!b; z-2HBQcclUz5Dx9hC%Y-0;um(*WI(6rsFzHO(nskbS~-ix%fvZmqD6-=!j&Rt+_ELw z$}FbvVC&We-MLDXA1|73^araQ*t7* z2*5uLenE_hTr_FYmo~xcXmi`WYqOM9nI+!&Ssd66)(6k|k?WN9sW@Mvp*-}1O^S5r z28Mn(!jzD$Teni~UnLLCWrP)lG(cKUc&1PP-d!zjsisaW`1%I0eDIsYmaSVPmMCM- z`E$LvSvV*F@c=pzVEM9z62-Kzg*jF-Q-WWBXAdc~DNwHmYrB4|QyGk#ANz+N?QWbQ zu4^GcnATxa-nMN!5&I+VwmUj1F5?U0B-k_WyAip2^nG1e63oRD64AvmR>xKEcZopd_4xC8rlNM?PL5!P=eOtd1|sL%ru zXLO{*a$*~lrk=!_=}bPY#+j!{5`Q^+tCvJ#1NDLvB1@2{SOcZ2$co+ou?H%m1 zKwC=gJ9^aDib433l#weWGaqm1){BS|D-#=Sf)S&PnKf&P*{lv4IMgD@kdS~A5Rr)! zeo&x*4{gF`$&yva!l}|~|7cNq5ZBx+ZXF`Qkru#o3snaC7-C8sg4}w7 z1)=u@lM4CmIaRd$#1+L(_e}*HK-P*l1vz!h1PN#-ci&}-F^=dcuWHn&CFO4*wnDT5;hrvhQ!YwtIBS48!oQ?4S0pAV=5g5R zfBxBc_rugF?wV`MSvo8?uwntqm%GjaX3vym$&Ww%XaN;E-`7PaqyMpY7VudeOB)~D zA$V|?(w3GwRH*y?D!1;gx8723sk^(o-%{aLkQS+-rG=J4u>c`~5E6m~@Av%YWW!4u zC?P=tXMd3Qeb3pmGP`GXc6R2O%3|PmOAq~`%rG14jA5k4xxXJM_V3?U^?&O$A1=un zI`68;HUkHI>t#Z(uvj5wa!CHwmjnGE7ZoArJEZZ_(-YC?nP*gOzhgk%{Oc78|YM`JZ!u}IsrX=iV~@u6eqv&aYL{QUDz3wpV|(|6#2VZH!A`q;x1 z;cA?qJ7uhr%M$>yt>G!@LeEOM)^J0qW_epVVRPF*;PPYnV|11=4%ojaOfN%NOitv}@uX zXB9yzM^7Id*%G=Py1&JJma9>-PS17i70dyha+0<-Fq0WG()1#*wO@M)JO{&Dut4z= z#Y6~XJVsGu%9N24ilns>BSvUIX6bi1KkVV;`u+Fcu(>*fsjfqc-+v!t=bU??R4Zw2 z7dmj@Ky7cUx!!Qtupbq^>Luw>lb!i}_~CmVehZ-gk3J%_)DoYJoTh)H7Zb^Sv;x2O z>V?Ev)fp7J<)255kZy3i?8@isdIgzIE%Fz;PoK|an?Bhk4)k;E403Yjc<_WJOLC?E zouvph!)@Mz$mxhfr@zX`!9XTmb91s)?$4srH0%HEAa_`S={)@KgL1Z*NzWu3%sH!B@`EL3+BP~nk z5buljb0sA$_IRb;y6t6y1`pJjjS}5)S`kdE#Wg~W9}&~p z#w{fND~p&Gayo*sJ@DXtZehYbe!unBOJbN!#MqX(8idH|*isK2`kg)h;yc=vG?nGi zr`EDnD}O29efLctvq_UCxSC}73I(rz<2kjdmNUKH(zypXV$2)CK(FufV!6~8pUO%l z*PT*o*QsyWbEZogT;`FYh7KKMZ@%-9J4bOS{MOsAdo*^F|H5mpJuSUzBcEHJ_x{AA z-Z-JfbcyaW=C_G9aNw8r=Y$Dg8!|oDikwQb1+GCV#oE~d#`IQk9F0| zZ@-TgV}D9?$W(uSRFP$McsS8U|29E$hakV@q>uky^JlmX7|>7N#MPY{4jkBDmR7ZV zXVI`hBVV{jjrv9Nzk%-%|8x1}s$-w9spUl8?xItis^z;<{ zmSap(BdzvHZWiYk^oM9B=xLW}FfkP(at#UNf40h4D};mtMYaIr(X}Qz42AnmnznXn zE;uoS>%6q}>m^B#C>&{xI(5;G;H*ME?1EXqXajgDL}sZXha9}eTo0!~aSUcjfl(VP z$w8=@BP=dnG$+3*zQCB18`hDOK3h^m1mYDkBt|#Ed4#g_qsSz;km{rIYEBSDJi-1x zK-Z$fr~M&9OGNKUUVLKo70RYB*;#2;v2q<-Dm!MRT1@tO_4-&A30Jr@jm{Vsa~L1{ zQy2l#A`)-p_!L@h&cJ8s5Dha;Z08p)n4N!<@yg81mPe=#g+;3{AILyRy)k1(DvHs~ zen|S>`|nsQ9i(A``QnSu>#*!zE$R)NuE9A8PsiW_PZ{hGg7pj%J9L|yovu5T#S9cd zMtw!-s4f#dVq}sCftRiz*13x!rz)9 zudx_-BNqhFCx^L7Jpra6l*eb{SmTg}L>}mZqY68-5ZNX~JK{M625@T<>t-%;4T(QI z$xW>YnV*u5G$zsEc z-V*a4^n9Fl6SV^g6E9Dg0Cugsh?qt(F_||obKI~oRmxKES6&ocmASr9sud;%D?A52 zwPl$B!y}L?>H`i#h+WAfX0o&DA+f;>@HC?xOdb-SyeLcbS@o@H>7R5xVQ zap(w`>6p(|Q#q{QFiT!`=s+LnFS>F1h@;bdnFZp(3c$ZhsB+Co}|9@qOzjjq1`H+|&-e!EL}NG-Ue(_iidx}F^$)<(0nsNvcU)BxU>%mc#NL6)2bU}&l<9Trd1%CU7Fg6Yw1+X)rToPGF#ov--I*8cTw%=67!%6bYBTvlTNq>B zDe4aGrw#Po@$U70F^BMDK)v$8hwu8%80H=@c36m@Po3H|-Lb4^&--1S_S$PNX|cxH zuYPk_zjOQ~Ywfo2e# z6ec1QHgTmy!lPp(@<#a?%;=im&>!+?j8~PT-%LKr4P}!zl#zHXd@pz}T<5vO6!NLI zLfcWWZQO0cUjv$^EDuQBIl>>JHCFef#^^a6j=Lzj;Bkvgi8_hubd9+i+(hVR-Z?nPgx3^W1$W{Lt7cc*%@hiLc)%c z?~Enruuvj!1tElIqP){RpXb$%a;_G8LeVj-q{8SQ-3j-gQ*?zkgf@ltguI*MC-b2R#ExnqyjY2?kJ^!VW%ciC z8XIyIycekmYDoHR=AMR;B=wg|q~WltAg#P#;VoXhYX4O*f5`GT8n~eF_c>IsjXYoQ z*@y;zf0n$7^%u++o+zw5o((B+?83G1S+LB+>*0H0pKE%Kyov4cdL|M~`iJHu(l%TR<%i!}`sYSx;spts zxPlkFw=TbVcMD@I>+)=A&cozq0+-~3g^p}r*@mK5M(52In=#`0#-MX}u|uG1>O@sl z*vq;x#t#)5ZCV=>-5BjCtPYnr)wZw@6qcvZYdnxA3tbu+rPr_XJr{{}%pZYkfXjs3lJ2!0z zIv1ynw2$*7+K4{8D02Ya`-XaFIAdbLg%hWZQpBo8T+f%ILy+A8Z6 z?ls9f6IfT~w=VTkZc&n#sRuZm=fF5vjbM?1O?-H#63Z;C9cy}LcrMT&lu?j2;rg0B zIh^JYn9(6<3eIU9))v;bHI-4=^}_lQ@-a8EWy~;p_H-*>v9=EB!5mr{Zs&ilt&IFk zrSQu}m&6%dUW7=}T5dPpc!z`0W733FL2^e2wqX7%(upNb0=~1_^Rl&Q(O3>cY}dj| z4d}hxa&RlmtY`O*Kf zZn1zl9he_@30`vXRgRMlvn#CJ&^Dx#P=Ee4r%^;Vu0?HML*vyB=o{)t>|-c5+$*eK z;aaGNyamT*)jcF|!h=j^Nb*#S{5`S}bf)Rp)<8p~C4knDk0Htw2%Lc7xY-$J45C4Jx6Ax zjPaMknK|d|v)s1(sH2XT=kKL%eZir4eiBz@%qiFBzi2~yN%R@Mz}!Z~0uNwUL_g>^ z`S@N@fL6R~N&0W{u^=u1(7SEhwpA&)%3H^2%Yx7QMDP&iF8$-M9=cQ4mXJ1@It9Wr zaJ-x$;S3W6PKdTXcpHXjJ0VY^eJR^UTx)}K33k@FXyZ9ny=DVrNOBN!OlO#Jq9X$LGiC{*U(rUGp!Asp zQwfeGej)D<$ZX`nsU$d8$QjNP79uleWyzr-HSrPKYtOxRHA!w?=DkeH3yGXE;vz+%)ARY0I|qIH`>QS5@=3NXj`^iEtiZ7>sLbsO$gF5 z&ttZj*p|roCK?-PA3XL#*|n0Ay+Mb{&AQVz+sX^{IXQqOYxnV%Jon@)poC-|)Cqn; ze{g7Gk(imD5-$1YZ25%|$OA_%USwx-Xiwo}aKa$0>{S`)|=4XdpyL=3bWkFajOsaRjnPxp zM6ac*wVASbH&~F@_Rf%RRT9G$9hfg5bOJ9;+IQdmg)P6Qd%q?nwRRhHXb;U?B`D{* z#*OOd^8}*uz+8ia9-h4+E-dBE(Yt^{S_AnzgfZYc3Ggl8XPHkVuvSQkwC8si6L)~p zdl#g2?v>WG(U>?=2MoMRmpud!f0qN@Oj1M|wuXW5e~9qUKJ%*WuwxfFVJx%Hg%9$` zBlp_tir<(bEERx=NH4tbhL~HX;w4@qB)a>=)E3IM93;AfgokpSygTbT=>8K=JSwd4 z-u}#Ox7{l^(StS7$~iphwE{bWQ3fjvR(RU?#N#h0z`@SmrorD06q@37{`}*@E#GOU zUHstWXMq5L>p*(s;b+9;B;5*6qD9M=VyHx#Imwwk6P8eJs6d{6>SZyrDjwXSUi}6R z8G6-K7wdNmo1c?uZ@=}iJRP;L$l>U<*IpJ9<7>X1$LkH;WH4C19q^4nnf8_hUEQ%< zz|HvLi%$gbb)W6O-vMH*xN478;E2=n+niJ@N#(Yc!%RPSxj$9X3JyivblgVLQ;zVV z-xB2Rk(%tS#o#mSjyrD`Eq{{dTMIWgL^5{gjq7d|&{Y(1Wx5a}Z@l>iXH1M2sY#P2 zdZvjG{D#`n7^sd;J@r^Y)pnEPQm(+1KJYdX8*=BJclEIXP8J|667*;;#6l!X zV1t1;zT%3@Elak_L?&VZ2>rTGaU}N?O&P1vLeIPOKj$jI!0~n#bl02!01GJM7hinJ z89YM9TM85b=6INoV46%43f~=?p9dUpm|cGPRg!M&iowozI04{miD0zPKKm+kT@Nj2 zlY|_2v(Gd7b^Z0XDHPlGiuu|=y4`GnfgSA&!eTkzq^D1lbh(Y3o$hlC8L-uX1$gt# zm%ZODTecRo?UxF!(ZXHhFW0;T#gX^z^;bU;9h$4ZOFW1JI6d2Mzl$?h@JvPw|3Pzp ziX$_E?*b$2*Y9gV7u@P}1j+Hh0}d(lo$(Bbe?&hPY%ELwFe9kT0s9~1;BFs&(A#?4 z(8Cv4yhOWqKS+2hFFQoT*I$3G03c8K+<_KWwx;IfCxZ9=&T)k(2h4SL(Trn{9@jYo zzgzK50j_JNpd!F>y#M}tKK}z#2AUe&l`F5j&~fg8*ac{g7zOv-dza?;5ANN$rn7{^ zu5Iq$6ZSY8uBmKed7(%TKJc)=$S=J3th?6&@k!_?(u*&=>8h0{pM2CA&RJ)ktE-VN z?BIhB^E@55*}-4#9Km^#2-Vh15rIhe-TQzu%10l4(6JUCeBcqke$CZ4*>T66AjbEJ z1Lz!b$l*e)yTyagqQYV84WoJVk!L(u1Q-zC4(PAwFQ;h|TyIxhb)zo;Z}alJ^_m#W zp#t)`+sp1KW=Pw96+Q(pvwI8uj&N|eTH|>b0ta;Nyn{1~ufP7%1`i(SQA%#R^&Z!4 ze)7o|J{fPl?MA({X{z%YFPoQ-7r%*^V?W`(@Z5A^b0CQm*AoVQ)m1kr%E@WM#QxD4 zBlLLZ?T`I=Vrl}+1b#Np@}lo2=?L}23&NmgQr~gMJ$mWpNLqT%@8594O?p-bsG_Aos8ds?3jphK?VcK`j72^tSO_0`>|x)TJ@@QO!o?UYDQu!MwSjrT1VFiC zfhVyjLigUHdEdTYJCM`E3N%4#Em!?R4?Rq-^yv;uOyG!{Z@$?U%dH;fjrxFo0ibq_ zyb2j>Q1*}1d7f@6- z(lD#9&v-DBr=EUHhj$q|N4ky2TxxCCuAK*~_)Y@H5dxSxUjdF$lf3)Z+fE1GGw4Wk zq+^dgNgk3j9O$Z*XhcFF0CKHttg_Yz@yU*5KND)F5OYAP=v z;ZFo)SH{(MufG1KV*v4;_O?=g?4O792>n1%B>mr!2RoS6L@km; zEh3`O1mXS=0SRH`<(FRZd|<5~c;G>fUo&Xn00{tf{rbp}qdbZhYOs$!df%^IebsgH zmYroc-*ST>r5o7;_dn# z{zdvQ5a^KPSV6z;^Od{eKKbNxLMpt#eiLBU4m<2BOpPPtp1nYDn0E;&ZFiR{=VWC` zSA2$tGCS(%qa~$JQ~B>Z>Kpw7A#Y~7fTo0ChonH%Af$4l@l@7}g3C9F$vj91en|f$ zPS#;Ok3ad0A_NVQRM%G9uX2*!7wB-PmFHm&APEHNWq(N@NPi@@wE=yP?eS3c?J0pn zkwEkdQH)@=?|<-)pW^hDB-E|jo_4jE^V841BDKb1UpQWR38~d|kadbnY+zWEK%pBg zK(XtuztVqK7h^t4QU{5}5mnPwS6}SEyX?4=a3?bC`WtVM`XP%NV zJSap%XuoH-ZbBYRb;#81w(THBbEq%shaY*Uqu&w0_{}#z66n*{4s8nkL*H4{ki|JufOnl!0#q%8%)@f#$?Kr$qp^c?|t_@z*@D8#tTNgB9EaQbl}0l5dYqK_KbFT zz^aDGH1r925Q>ciHVy9hBtX`B-hQ{WQBa2c_upUEGuaOMb;I?yOZQq+NQx12{^+B3 zgmM0-=ElRm_%atz7m!eeFt#KZNm^Q}r2iBBx2p~H-Vm&sc#$pD_G_=Z(vbi^_~0#j z^_6$EE7{jo0qhDNd+dIH4%)M$YSN^wGs&y3xkAr;tewLvj;?spNvHaI0Uh~IV$uIf zFtA}>I(&laCX`;f-kBkVyfeVRE|EKa6^*a5<|mO|Nr6xVzdJu?relNy9tJ`oY9rJr zjT$v@)#0ClnS~LzX|t`og3S057N%{@LJ69GY;bLt@L#M4mrCitgT`MN>`Sh}m!-LL+UpZ`V=8E#s<%97O~% ziy$hGKmG(AIJMQwK9TG$b+dhfS+T+*`IIYPM$n%-`6LB#ki^79PluF*I)y{P2@@hc zCOT9cT1a3}vlS%mz4rlj<4srlcO-%yJ#O;tYqlmBQX@FyQBpXx+~|3JC)%kd%x_I? z+0AO=g+yFYq>(-JGV&6$_1sU)hVK~}vn7G*#C^NA!a7;(+t3O!og%t7ZD%0~iESoG zkff&4-!th!e5+euF$Tqm!J7^V1_u2bH%fLU9%5gH`>3`8gX8UFa$~Ob^Up(tY}iM- z_J-QAr20$3j)6p;McU$r#JJ_Z6e2*qx{`XOGrstuizKBV>1_>+f-ql<8!7_MIy!aQ z(c5>1;z*K+i^_O~#8|JQDU5GMxTSW@uyVWSmOB5FXOfqJCHz zc4I0pk1JIZEH8cO)TxW$WG6e*;E)_#4;U%Z-FfGns|CBGONx}o!ba>&QrE8C9rP(% z0!H1sb)AVbwn%>@*uOeB8hf{#RRg1IZt5nE2ZKip~eFPzp!_PF~Luv zFq|5mbqEK2GQYz3Q73hw$|C(Poji-~B^O`fr7m4| zR}hUEzKD08GE5uze+I5KQ@-Z`pF1PgI@4BdAp@;AHw(YssPLj}fkY4Uz|C!r#f8|+Y-KG@k64VaIN5P?$uS;xy;$AlJuVO#giD0 z|32;r*Buk=fD}Ey>LuVXE|f`sr>0ExNaH6Qf3iZGy>CbU>i}2DP(J4cFdC2SBsy^z z1xOO{H%Wj-ElpVXcqY_D9Zl5NkYIL1-uq5=+KV&=t-P4%=y8C``gVo-Jw9z$NR!TK$U{;ObeYOafr&! z8g(1=)GUa~(qCw;3E;n3s6y}->cm22p73wXD%w}tX(KeC?gN?{HDl}6ZM2hUxGEKJ1V!-guF{i)8J zVE{;{uf6`V0k!$q6Hh2I&Ph^>q}j_l5F;!ZFD;TOFUqT8dg%Q=eE$R2_nvy{>3;CR z0HK;W@7xQdCV567qsA);#PP02yz|bR4aq7<3j}xNkgrFNTb;2GP5$x69`S7@s+Mc6 zxyen=+PCkZgRF@@_TXrvb_fZ^gc^pFxWc1K*hK*<*fJ97gdoo7`A7r~xaH=au2w+B zL12adTy}}8A2dLFfDLd)y^;VmN}EP2xJcfsklh;_sT0=4Pg+_=2^Mo?E!!8mB)C zIfqj3zWcfs$xB?<`GPEs*z&h(+1iU+r~~V!V$>JQ9{#!K9`gfU7!_&ocLP1Td4_@| zv!I|d`CO4?NE`%X8W$3640Ft1`{>>G0NE#NjPJbjK@V_%YH84*f&LiJ0Gk@oDq1}F z9s*&%`PLh*dYLvYMGNi=QbTR)svQpD`t|!>Dxep1rgEw;Xt&;ayHtq>xT*ymGzX}M zNS`120=>Z2COHU|h6jX6EGszpn|#?-cH<4V`T}wIVMj>1-pK=EFgF;(kSK#7!Rb0U zMUQ;TEw}r-!8uK`nE2z8K$DW1`1mkyNt~q+{tziQSv!e;AA5q`|G+(Kf{xYz06+jq zL_t&@KJJRkua4ds;dKA|*yBBX9RZT>yYDeSPvH<1H4|u}+DiFC#rP9xi-hG2^#xcI30(GQ z*Ml|9lBIc*!D}p?e>ON1hb&BffR(gywEtXwsiJ~BtnJ!)0@5Gv!3L02NN1mOvUH0t zdjxJ)Nml5$-+o&hR+<5N!$F)svX^%hpm>={^nl#a0 zepCS@CKVCyLmH<5tpt7`F}Wjl8A-k9g7?>94$|MpAAe}~-+zZr$XkjT9AIxr4}9vW zXSl@wy(aAV@uPj~O#d>2jhM(7+Qlhv0dO_rfZlrBO}d+_!`J^gEbIRJ9i(%GmcG!2gVL!fDf!G8 zXj~HQiN>Oeopatf5+)wkVtuKf2iiOjc3{K>Oauc_Sak& zka*Wf#OgisjC0(-VxCmFSU-_gXxD%sSa9d&H%rlfzzu%x4Ut0)*nJuO618_6V$#C=?xv9w6EP39gJ07&n_{+hf@))1Tkn`;~@ zu*e}{m(12S{3b}jA~ACq3DQlXU9&%s_lNd}G2s~)e+V={0EtCbw2NC%?O6U;UrQya|frf#5yR1 zJkTnPBlC~^&;eBe!3vm5#~yQ%Tb|t3^JayR``Aq?xnEd6X&35|P-bGIImZY=7Lpr9 z0XhOxp^Z4E%*mQ(%a*J@{}KaC6r?>gC4^pLFb?WOvL{%_qP!p-$r4VWV?kdB-Y@$6 z_jjg@%yQf2UuTTLAE&+ir@g@rI|@89L$yY8pk}RPe-9%%u3T+S@a`i#1=>-6w%~~C z*RTDzxlK?IoWVldm>0Z1a|Fro*9y)fD(S$vO}>qLK|68iK=L^*!IFM%$^`w33q`xp zn7GDRFNYX{PP74#M>4N0ClGKT2*yCf7`8Wf&=J`~v%2ZjK)B+R6bCUZo4)mG4wB*D44Bg{W9jtHl!P+~jKu3z8*7VE5Y44u-|zi!i$kf-op;gwg>JWu6zLv@NwxeM zo`p;-`VAw7R+J56fk}Wz0mB8d6TK$aX)_0dFj2PHA#eq4L8ra|rzW+H-3X319PIHN z-{~8AN?t(df&&xMW{_M1ZQR>ZFAMjf3%ekKKVY7Tst73y6Eo<<;T118I42}9U%1Wz zBRXyzSc0U_!7^Ts-p{;ogU)yWR@ZeTE5?@jLLTM;2hq?dj1?+7Xb8=8Tm9fbEjqyD zH$gqfgDQmUTq8lxx;l8pfr$R2LW#(2#sjn=Ucis7rop8cQIyV`l zpv$M}fVxxub2zjy2WcZ7!?b-K?bUq3ThN`A^c{8263sILlrV2;KjV-%ADNo8k+v}R zc~9`Zg=WCJGA7Jn7E8v9@rL$b>ChL(hIzy8-}y=XsT;d=UgHrPB)948tOYdJDFx;O~eb&$ELM+($3=0_hb;S|eeA zFsVac%A>8k$h4Em8~PpUArC1$lSl*F%nMksZw2%6@^CHCi|<@Z{7oH+W%HX9+6OaW zVc;AA##gWo+MwqAtqh*$rGXwC5OT-`UAfMG%1Ioz&~NJDWo7X$Sl6ntB9tC;DVi$< z^A-H<9m@L?l@Z0Gq>PZ4qWB(YLw1-c(lpQqIP*OF9ICb!KA-Z6Tdgm&xgd?g7_*2% z56Ta9LWjbVUsqe8PqdgvbT9Z^M0@BNmBMo@_~_oFkb@C@oVH|IEuY>wwFOUG?vrR+ zVed@AdZXH+zlm+2{xIgu>4GU5u2t`T!QbSM<~H+&!`{`g$hn_LGw+w$91?Sl`^;IG zJbmHa=UV7P$h)aNX>+M#;+u+Co2p(W3@=|sMsyH}Mayp4!V?YiL(1|)F4gNOg{lj4J#^n-oJST1(dj;Q zVEP0$b4NMnjFV%-yMk5)=LPdLFd;JKs1VF1kzBDw#te`4Lmxi~l?o>4gP|c-8?*_U za6g!7(Jtr$e1tZh=+Nkt8?`1oH^P09>pcZ%nQ>?%(u87%)c-Xqb^MIu%^NK z1BsXWl+W{+KcXsv=KSVAQD^Y7`|Y>U_V^PI8us+qYoiK*_S8eUiHYA>|6rm9!)H#= zCrmCG)9&35wma{*+wnY}f8iNt;(cYdiR08uFTbEVS_*rkt6g*Lf81guvf^01j>P)n z?=c0o6bfuqCeF)`p?(0!hln@~@S+BX4~+L=Ucre3&>xuS#EHM>(;P<-Y@LAmb|yz=cT+xqJP=tdVl62m@Y`JCk6fVeFlHjY# zwS1r)F>2b0C!8WCRZk(_&X)7VDeiDGX3Pk|SvFLI$~fdDSlB>x4jw#E^~`Y`hn6kc zco`i!c9g68S%TaA-cAr^%OW`n)U26gmt1m%q``{rTHUyDQ)jB+Kb&*Ug^CW-&;}0b zE7eFD!Py=l#JD{rVR!QWwC~VK?$w`*@zfE+m9vSY{kCn}3ybAu12+Vi(oaAAC~B*0 z#*>oj+C>*#=5-C18+WUgt$eXK^w9qkO5XLtTG?Hg;k|76%JS~ygS{)Zt}KRlUxJAa z=6aK+Ed;{!r51wbzCa#+_%U|n71s%yW*;pcz2roa<-rQNb?Yt&*Y3hDIZ^PV-^f)t z)$!8L7SwOux(#g5z`oW>IMaBT?zGb$cEb&~8ki*g`wfz3Y?^iyNwSeXMKr1{ckT-n zKJ9qHdFE(g`qiIjmjc}vi#E%bFP5|{7woh-0@0f8xaa4n9)cM3>-U9KuTk44O<>?S z3lX{H#v3)xr=8{H4IbQ2i(y&ozR&(@^9h=J%QV+;@~Nk>KUs9cIpr@KI`jwOaUZ5# zRbxrz&x)?~^78(}#9aHdjqk=3*x(d^r?Ts8(xkEP^5+VTksWJM(ngWQkxKEJLYlqx zmb=~C4J_b2wEf*>=Uwdh6aMAF6xz4%Agt!Mz5V~W^a?TMg^or1*=Kz{stJ&*Y^htd zY9-u^d+f|J&#~G<+5__gNYidz_q5Z`JWVKTe+ZDMm4`~}D6pMVPB}xk30U4D-MLeb;fxw-B zY1yIUHezsB3T|{ykLx!@QHKZ(1tUz^$uY^RKnzk3Y?ECT3?U9GMoLetq|}{SG|DnKAe$V6}kZ0yg{}yLYt%4nD*eEIcYT zCvD~O9HHUe=8AJ^dS{RAlDZKK1x(kuAj+lI1N#>P>Y`LF3`Q(GXL*a!W`A5vZofh$* zEuRAGciL1$y5LQiHd7#1WokK?&4-G(an#X=*jeYC;f&~C|N6IpcV2XTC!w+AC@nQh z6OU9wdq1l@iE69>OlOhee-{uU$k|aG)aRdZEQ7W zW81cE+je6&wmGqFqcJA7ZQg0W_r8C@JD>bCljoT;=Q;bFz1QA*t(<0D*V8U%Q~CNd zin-aksjgG^hYed^ek;6#vM&OeaegII=;SCcY;!HWmpsRZfg~+(G3s_nVpBoD73(JM zxrN!YLqmisa$j8j}&-^vAly>pUfY?A41Btr{w{icah!0F?JH8^ zlpW^27<)`SW?qfxEROD5jX`(>e@5B)c8`5=GTG*I4S5241}?|C#ek#t#RdK56!<_T zY{@>3=YGpy%1N}DlOW48FA|AU)QLxIvOM6QSgGJd)a#>+w7mwZJlVEu1ywf`(h-wo zIs_0O3)cI26^K!O99V;ynQs@sXxO!SVp6jpa@#d+^r3pEp&=Y|KQ9^fYb6Agd;bam z8+Taq{o`C`#*dHGgWCDx6N~$D(RWtaW-}E+3l<6s%3sr5DE-DAd^$9YiJ$Xs2FGka z7xUF?*`VER=MBDPv)>kiAdbqx{VYh}HeB<;Gk&ru%Lf@0F~r4{O?{P2lL3ycg>+CT z!a9+ZcF4I&g#4QG!t>H{j%^LT0< za$TlE zTDGqsqjB>gUfYpjTPU*p1g3+|)c^gMw=}cB<|lKO+o^c5CF)W7k!i8DtIHt#&N=Xa zv&E9`VdL;hykvGT3jbhZnmf=`hbCr!{ekOzq!~Kn@x2$gZoBc;A^T&%tH-EHNj<(^ z$$>C8UI1KHSrf+SvOCa~gPNwE03H|4g*?WT4I|{8@KX*7fnDH>cQd*#Xq$|usP0L1 zBf4@ss>Z_LxFy5oqH&m;(dkj%9 z4{%tlSL+=lj&K7F%goF?pG~2PMyeq4B@i|%mfn`RC~xc;of*uIkXpq~g3!GUSqiI^ zsy^K_U*zqS^D|ljNKRC#`#rzH%AMZ!X!;X@#e7-*9FnPI0M9f6$0fngJn1j88CN+9 zIYd8E$sk36Jes*rP>>+P_#_C#XOk}dDMhS{2grYm2D~{;8q8%b*tGb?h2j9Qb zxO}!C_yQf)sG!?v-RN^b=A7AHFh5)(OdpWoMzfdZdsF4M#C{L-cKcJy_jY}3aRSBt(e-BjqUQ(lF23u&yKnpqZ_k#(1mEk( zw6LDHdAwgr-c7O~NCmn5_5UOZnbTKew8QYr|DdDbI^ewxIpGh_V71LU@w{lODh{B3 zh}{JUDx|LFnws#BrKZzrJ27H&7}eh%iO)D@AM^w?#Su#iSR>7Gk9@^m>bC8?6FccA ziIQTfS?Q)UDOgNRO)VwpUG%6YcDj#1pLB{mfC?`OSlKR0ud**iAIT5`&QHAd_hLR5 z(v??QQiE%Qy6k>PJ+g2pb#_BsBV9JSM<4|7Nl#75elr38jN^GK-swr?2Ms6v@&1<2 zMadc0cq-9O(+X_VCW91GSsml_Xk|tyNykR(7NaYeciXa$ z40`zj`uC8}ics;)1(0wK+tx3t9S>2-_CBHWbnI56qA zq16>{uL4<=aOk)mY8xzpv|MPVQrE=2CERhh;!p&P^ols83^TJcD5<*+FJ1XEm%-FQ zNksT(2s5W3WdNGQL6_PhOnu(>pAsBTfv75Qz^Ah4U1$|173>{Uf~_*DDrZzk|7;jr zVUP2cV(%WT%d>B|!<{s*hpG3VGQB54N^gNQ=#vaHrV@6W_ARudg)UV+zXR0z=BQ-* z4~U0WSmR;YF3On+JAG69)o?P=PR{$V^fHe+(c@xb<_IR(-)(+NlsgfMV6&8zA{-H%T7gT^X;G-j@1r zs~pW|^>~i_yD;=jaxNh*e(-D#<~2&CGK#updInS+axELHR;uAu+O-{0nN$YFvWT*H zky#_{tNe)(9kc9P4^fj}6qWezw{EN8ZMo<30N8aLy zFrl&^#DkCwsW==?%?Of?&U$R}ckuR!BfBH!W#wsB^QObQSEB6qyB^~c54^8JlhX62 zs-^O@Ew?zh>yvgJYzVBk#o^;r8{P+~G*ar7$4?hS_(yg(I=a<*m%3idqNHB)r!N;! z2cllkCx-PdjEcoFjzhQxf}IcRCkoQiH+8|XJWp!+O&(Z{+xb3td4c-}DBrn8rf|CX z@If-~0cQ2F!6U@Qvyqr}YmMm}eJHjLCWBt$gVa9VSxKttit`7ANjy|TN6D3fVs_iS zFR#eDDK(14yu?GkmD;L1ueF@4-&dc^m*?sne^ltYxD+8c)p6f%7lcxrAA`0IZSnQuo__SKO-v$aGo#U7>6JpPh%E^rwiXjBEsX3$l=NP(#{OS@*RnZ z&<_$iyVM->$$1N7AX?Alnc8^buJIcF4E&aNIxNhLeluRE2QsO|cedkEL!1p>IdR0a z(s)XIwt-^q7*dh)(7w5PCDZEAdl_{D!U z0vAIfiV|FyVY@R;a}$uyN-FUM-mlhD8TI2eYW-cQ!&z}Y%1GiZDe+2@$t$R5FaUmNgsnL|nVGBkO4OE5AuAbG! zZmC`7ny33N#eK^qBeA?c(_S|p?|c#TS>gXUloj*(%G6_=<+bbIN!SX{4+HLPPFDF! z_=j$?K-EgUS6Z0*!*ryeoiLV;C#h{7dg>*BO zKt+}%F2&~x>qFgh-oeB;E|M4kbdgV0QF$*Y?Wa|ui?lKCIe%?ZiHOg8N;{@lDsrAs zy=-}OmGYkv@sHQrPCvah^~UIX=2DCg)L4CvxPEQj*Z$i@L>Gx?IeH>#1yh-7Syrr< zzQC;8bFjSuIx|FIg^GPO4X1W^hEPH&zA5U#7qfc46X1V3$mIwsL* zpDxkweFkXqZ{3Z4=^{-UWIet0)2CAuD&HF;(kg@vC~#S^l$Qr6aP{YDeZIlis#_m3Nr>Tn#WuO8Dxf)fb!GJ(cp@HIZ2VprGDbp7W! zd7gZM0EKYb$_%G~#t$mblQA{xa$BnSVDIcUIgEQu7Aol)2$Z~<@aaue`yyabOzplD_K;gXc6wf`qoMR}5A?(?9f>Lli}CkloDS$?2Vp$V~lS#9PFSKT6J^J+e`AJ2;ZTS z?~uuaYMf|nBmX9jsv206zM^D4JKtXFfwPFMsK=NCCdJ`84Q{zu6Cv=K#km}Mj*R|h z9UjV*M*7Q6;GW#P1v9e`rw5-ifDJz+sEk)Mrd~T){V3$W!EyYynrEBMEt^T#2ABBdADNzGX-T9d;; zpT;ca=Lq0Ys8(uaksXw!?~Q%Dt(3KucPf{u$xkIY3neM5F1~3NN_gnNpJv}9-)4KD z?d?~puvu5B)dY;++F59160B0WSvQ2-{ovBoX-f})!IERiCZy5$eRB3#>>&8={8T{g zqNZFnU;R8HZ9NjN)r7B`Y${0vGBe45aC=O8$ajD6_QUp793z9IUnCOZ&F6A9`x(?9 z*~2L&lcV(&rx^_l{KSL2WU@^y>^GU;KLpX=UD6MRq%%y|Z_rvid;iS&+)$*_p4zPdo~#mo#CsIVj& z-zTop%y#8+l?F0=?m9N}j)W{G=U>)cYb-8f&L1^nbR3Jv3#En;=yZ*`Lo)SM z(9w+uXF!fwZG|sCLd0kHqtAfL-c34&qBt(Gg@sdSt2qUZca9pzQYQ<}uAU_a&W%Gh zZEC;JT6xKeS15`zOjvIZ_4|gTuJ4oP_!!Zs$Cb+$VognHR1>YKW!@jS#l-I@Y03U> z*IuyIYa)pOv^Y=*bu=~t-1P3x)SibujO`^n8yS`p=}gris%&7YX_jefo#}EpzL&YS ztJ%&tyDGfHmVo*oQ^|pK34vA;s7*&_aUa-xg}G2E4aeQ~$7CYQkhwp{a-l+h%;*E& zP-v~jF85N9xPi4qRaB}o62o4Ydt;e_(NuHg)P14W-B7%PT}6AU*XW4ReNbAgrmeE= zkISmPspszW3&8t?l!eT)a?PR7d7xBmwI1)!e!l^we6z^dVU|(%5s&N0!||EselQA{*&}JNld{d(&n}WZlRn`L6-WwRwZEkWurn} z(VvB;Mr7oxb#_NxJMDL&aE@TzIUjv3LuO3UfgSWuv0P%x&Q780j8O&Efs z-}TBcqvM{N%nNjWSUb%LL}=g-?5vT&b*TY*t{Xu>c~@CNujq%oWurxfYh0GW4q-D7 zP-+k!^^Kn_h}Y&vb4^U^a!du_?edsTUQ(_Q?IXia6cxVk;tO>sh3YDjd?Y6G&M zjS>s8KS*VizCZylvea=fFR^-X)xRsux55C^oeQQYUa28Z+h=*6_KXOZjEuF`d{&|2 z0JrI>7hJJ_jo0uv#^pU#w%JIN|M}1Xd=rb_poIJBR?)*HOxtB7INl2J3KHVz`t4!8 zHv1R+NJ6t}$#ty@h-MUcjY@Tv<$11WY@aBeHb)(T-(EFemT1%$g4i(0+0=N((SWYV zJ)U|Mi8*^Vs7c#6Z8#YEq}&MO`)-b|?cm>Fw$Ojx@t}Y4084)zYjzO;8DTYq+vs}q zQeNeunOe|z?|!lF0s^vuE{}0CH%nKC*2-| z{UIgp69e57w8*f%Cq5?7Q!GFWqY$gqc#8Ys_Z^7mdm&el6WHYGnoh$_KLtQ(Rvdt8 zTQM)rG>$L=cVBZ>Z3zOkCqauXdX+ez{uwM>uQ4228c5bLbF8cT{a{tDp8uyTEuH0zYvcEC3X3U+lR0xv%a!^TU0~^$P2)+W zb_cc7mE-<+TtSx2S8SHsM&Zn>uJ@4OQiDQRj*Ri^HBk^4d0N%d)qI|T3x1rwTsJ+H z+^_%uE`?Xfw=T|A<;(5LF z#^y50KnpA$2<3!fX!?aOblzaU}r1UhdgYtbp2g(uSRi6|pcHMGPe2c+Z=_>h&zer~NWmsiC{3X>Kq;l&6eRjKEHqfi7 z^ve7F-ScWpeGy4Qg<5S(J|m5Wp= zm;7-ig^f-q>Q@DBj{E*yCdE&Oi>9WNaRU$8iHSL-%#z|~OkV|K%z*%Wo zUH}G<+Hs>mKO6uZdm{NQ<)_Dqx})P7M}d6)pvxvt8!)+px9+X=;wU7x&ZJN?*dgvH z9sj3BBcnJ1*N;IA+~(d;(O0g~)Z@ywa~ocS_kI^(O9b)KuV0fMy+OQ%zLi#)Ao|!& z5YmaB<(u{P)2J3-tJlrxAqgnbNRT0!UlCc-eQI^GMVhn0D=9doCy%M!UWmbYRg!(if|bgQg*VAQyZ@D zxpJ-7^2q*FwrNM#sfeTUT2V zPka-Pl~8R=Admoi$6^!73-Veql%h|TMky|n8EXb2&|8?ocC}W8V8)EEY88iy?x#iZ zm)hOgU0$qRmtzEdYpAMDw`R^m@V(92Tr6lQxL|KBNo|Gcg_N+^M!PZMYU!u2UnijB zwO+;=J!}){2P!-6MH1;J-X8`&tTs3CPn$LdhXh@}p4^iGF=dd@~-xIFMrfFIc6!@QDBKG9JaiKOLm z$;OpSBe-5aGk4YJ==T~Pm17IC6S-AGQaP8-nl`MV#Z-BHW}HlqHxQnyq~G_Tw>%dc zKxvx6Lbo|)wQYFZtNf0ad(Tcux8tLswO0nyb#PwfoC(AE%4nM77ux)S!619>vdsl~ z%6B#p?za%*z-+N_)UtpB!+JzsBq**SKdn8H5?3THr*BBh0Rsr@8bl%IMj=>TQCMa~ zUWY5f689bO9H~Jn&FgD7FJ-hEj>RYWLMhRPu@t%`c-nH14eH6|DitmZE8SgLs_9zp zC27t{c5b@_aZ|O`w>s=G&>v2#6lqCg->xCT!e;D6J~h#It<+6%=eI``1Xhw30cF!L zZ-(l&mN;Z}T(4BF$}0Y*y$|1MF;98__7HfwC5nGzdNL;Jai z1nRu%qft9=z0hB7WW8>cFd-E-#SgL#1PMzJ$5%C&xlx{36UnMfLhXmU;B=#rB$}@l zOsrUnlSje!@R)(rGVqPyu~`J5VHrS0r2Q0s-A`skun~uz036}K<#|kY=wK4UH$rLE zKmMeRvp10xJdKOpOkcLGgmk*W-nHsJj!7PWqi2Uv^)s!}{wKcd^&no|2tmX{J+F)TY$mDOy8zWsasig>%Z;DiT+nYPL28zBJe-B(Z zRp`WLp4OPF(g$d=*lUbuFas}*xF1#{ocvyDy;oi&QyEz9gJU=%(usbxFZtkeJ7q=$ z?B5*F)voO-kLD#ZIG1H(e@?5C1gsRgvq%p^N$eV{R0xEeos3utQmx!2 zS3EuK{qKor?)qS^4fv-uyq5eBDY*1y=EV&oYfa`muX@O$i0)RU)?I>c*VJbs9DCIX z+>&!cC9h&bwmR8(ceMh(=E-;uxQI_?h;0G9q*4u{`0cV5Iq+@ANuGe0O5kIjNXvlq zT+}W%8ROdE8&7W_We7_p;4~$yDvIN(0|ad$L-v!|@om{Tw&Au)#8+%ZHV|Wo9qu1X zS)m$kdl+zsbs={tBEU}%=y((JAZy>Zt=f}fR+8*fsbw8ZXTus?GBPq~3m`@afp~`$ z4A@_3>njAF6_)C@p87q1PZ9Zhd+q$XyFtgg{wD z3EVj@fo9i!#zu6?O9}@U<#}(xcM2 zRp`E@j17RWINRgSB(JwPGFmhWbw?BNsDM5B@&1o1=1@kECk{Z_gcQ}~pd%YoD3%^q zEgya>s&_Fjw`Mg|R|!d7R@uqahEpJl3ek-i?%-s)9P9@f z`1suKtM?A^Iu@I}5DGmfm!@ZZBdPj{Kyd2%!|t)9@6E|`WIr#jSJZ31*_H{jnQW&I zt%hpWpepzQSc`s@&XU2lo5wL*PRk&91nq5o*DuhgpnwYp#I}>H7@Mve%q;~thO#@V zgiB{~$zWV5vrs@3C(t9)P3K0#MNr5w!4kwFC)0CM_1*1Gq%}3Hz&1u$@`Sw+vOA)N%Pa`v800}Lc3WN7gn`gXHvSzRTNM;OqN)P3pFdB- ze4`t&m@PHmeo3QzEv^J7P_jQlEdFMd0b#jZ!BH*DLds!gBGqm4iQ$v2)M+>U4 z3Iuw%AI{_baQPBmG)3JaMSQW%<>IX%ahx#etjl!Gn(ja4i(=Q-oa2FKTv^%Q1&ib2 z+MD&$+lmGZr5DX7R?Y!{hVM7j?Yg<+mrt^LD8VebQUj!@J5VxcOacL7Q^_Rq=Xp;C z>xqD}Io;E67IY|tzc_58`?rkd zv=^YF(P>}m@%WiMsMW&&)lli* z>`y$L@yS!g-25-#Zdm|!jQmLYip&Gc8(h_^8<7@1-d~!Np%Nji!TuLQ0crU(;FcJ} z-KPH=2}F7bno{i3F7iKB@T6>y0C zl(Ws^>31UY`pP4Tg%V?EG!-sq97&nnLkfzfC2{QEwM?b~#sY2IQ~x`|>?q%oNibZj z#W$qWS-y8wQ2C@9Qz#arMfeNm7|`3(5yZqT0u+bh6R=xQI)w<1BhY9ZJg=3ThcqYM z`nms(2=ugD6>}mrGnD`u^MSquQBxVIHjcHm;S=5Ff;Fkl=tLr&R?7^UgoR$2D-Eb| z1xX>vy;`ITaaAvJL+t-D4AxaaPjUto4-2l%C{yv969}MrH>N^p*s~pk7+J1XZ^> z%Xu3j*?EoQ69*cFF~cJ-NBr?-Z&2h)0Q(!%Ft@DyyKf4#Dy>F$c{7w=&U{lEtY;T` zrq(b^1cV|~}eLJkKTNG*K$xS5YgL*pFZU{e>hw@*u(__lfqO=P3@ z_uFLr4H)e)*vx{d3V8hOwY{$97h0XMv0sKR?pMlfOXHtZnsgY;<@3`R8LyKEAtvSz zB&)0Ga}xm7pg!DVFUPF2dPi2yT~KsAaFuTRtl8z$F)++I5b5Q-J<#=cqa^2tMTrQ@ z{}6fBArwi2>MRCTo%Zp()h9>cTm>>)uFJr822jft%zm1(?RrC?zlFC1R(9ZDev+14QNU#LHZ?MZ^m4)Jf z$JGCf4@JQL&Y?{-DgYIhGzioy*~)A_GpFPE#$2vbmjx1%kS2ALY{c5vjxG9m_;iD= z)^-mBg2>Yi3oW z5_?EzFs@)O(G@ju)}(_#R4l7Fth3u9;+^xlnLxLG4}f&J4kKG0zon>+q3eRgIsKJ2 z_-_}nvVcWN4a*nt|2{F9Vj+rWU(k+zpkAeOp;5jN$F42DB>YkMQ-lpJH){!{)M^exOROCF>5vbWfQyxX^Io!{ROSYqrXbWJvbEBZnXlbf&T??7u$20i=Ov73Q-h zEFeX(L=^F4lERNl4Vn>)c^flO0G$YMZ;7I9Nup8ZDO;SDTktKT)@}vW7MLGCUqwc$ zH@@I`E?tv2R8Q`!*h1`_(<4XK zIhE!VhStyZ_BQ$oON?QAm1mq0E7yB!2TDnn?^PPT36I|IJu0PI$eaD8If^czdoqueEB9@ow)0N81{0;^#(O$~ zLU~vmT{2}}El7n9i0P?UU0)u5-sd->+Moe=4OC9yc_>!`9DUsNp@U;h-XxYQslF2@ zx6=n@#Buaw`8XG>IXw8%IIp;IE9R>)4Pus<*A?AQH1RfLZbx&T>NzqLTl#~2qL=ya zkRZo{46m;msxj^94ffGf1!yq0%*v{q&97DAi-G@W;!p|~tX!g(FO|W({=hb6wYMxRH6e}3GP^2fXdQ?iV1NMy{(QF> zZk5i-I!5DO0uic%7PtK;%3&MjUqyhlKau!$2^a%oZH0TCiGhRfa{W=MiYgk#0JonI zRu;2knfAhtvE`Zzw&&CG$=R)8x@OGSeODcRX+|nohvB|Y zH$xe24VjM95GycBD0YigJlLS^-fAqFW+EN4yxcmRQJVoWUKya_OqbWpL?y$yZTina za3Gf>7Uzn`LRZUdbuPqGvp{~&pj74$1bHY*1V5z}iqBd#Mt867uRIae4`^SO59J{$ zT&qko;~cvVAUdoUxe*#NTI$Y*4QU%YTJbHLv^-OAYlgO5ruSl2#EUl$$D4KD1{bWe z4MD=8F4fv?c>1chZaS1H#K|6rJlj^DyXSSMs)=v6<-D3>3qbd`5Pav(j>lW)g@*`L ztpCOXqW$?(NpjTdMf zBWP5S}PE!QSnwrP2m^E{kn+>>5e zjpn*XYI__l{#_iw?e&oAG7gfe3p?gM-!0FlMG<7KH^EX$LY^}?x~5VGq%qfj8SMG7 zAO#|=`nxflNca=cU&$RFC!rqpMie`IBh}NHe*T@+T>U$he)+uXjW~If=4+`07#J+P zxDdZmwuf;L#%rU^)3gxB`QD7}&5~`G&qYj;%!{JbHJv3zrhDz}Zo)f`aaYV!j}PWm zFt^8s`~3)X%yJ{p_2!%XP=;M0_w#CuPq#WKQ{}skXf7Ea=b1eE91W?>28Zvb2P)J1 z>u~bfQ^*lJOKfuHAVHyi+V`1(t8nEchprP)usH`;51+feFfQpl0i@B^Uqo!@jVS^+0LVT%jHzM&+m3yWV?_fyUQ5-61_3cBReAK|2EOXX)}DrvVk z7k(agpwkZ9!~MSUg>O zslY+ZxX)WCpC!a(RhQEqTkyg^FPRic*Pv2Db9XRam+T@v!lCPuYNcXDQ!#oES~WEx z-ctL+ZWH~Bz$7T-gv7OmgOyLP2^aW&S}o_5M!h)Jg3#=^DZa&!=$4infPF%v%f=a* zMm{OvENHeexnbnVz(d@jHPt|t+pFe{9mH(-VIzv;or`aVL<*?r-!Ga8Go27m{vENb zQ%|bjG`Z+;mma>g0ZM#(4e=bYZln(q9uMbwTk&mhwa`(D%FOM&WGycHa(xlulUc2YxaHejl`@#3sUCl z&JJBUHP#H@!Ic-=bxTaf;rCNUX!GB;&E9rn#d8tKR0g`?2Bo=MR8p zhSc*7y65eBT_NYkBqG~}=MCOgZgNx0GUmPWg{skt8vreVB>**r3immN#d}n2Ieu|i z757b%cfkoC_i?o))#VR=2zCvd@4I`7)61Q39b3wEni-mI%-^7Hja;r^n5QJH+>Kzj z2I?1E{Doo6nkEt9upCV@u)}G-=TivnF6Y6pW&)SF!!e<9BJ*h8Dv3&+2~DZ61svhe zfijg!y}Njz`obv+I|)rKhlZBLyi8I-CLx`8u$-br%Bog`uyc$Gq6jSQJ)yo$vl1Gy zIb$A2iRaUG;Q#}qk^T=ohqV=Pko=D6m}6bG_sayB`aF~B@|fWK|2ftRpp8tSSPo*? z3rr^p`yu44ZmP3a`iNX@C3xmag*r(B(qt%iPeFXo%^>e)a283iuS8~!WY37DIbSnx zvoI_W;bGdp$)+t3=t-xUW-0ERt{y1DaFnpTX~0V@9lr|{J9{5XWpbk?yu#TFuoqBw zb8ob$>sZNaE%Z>|ScBZFJ5)l_z9i+o-3(l9dU%~pBMGPiqgq`$?crzz)6w_^id0=$_nLu&L4=lfu(fu(r)K@?zDsl zw4-fwCgM6;N9@0i=r>u7dxC28)a!o^We@Z*5|94Ccc#$}@aFLRDn3~T3N3J;WNxdu6)NY>ur-ggmj0pIm7vxeZD({?|sxfELq>n*QL--Y#GiA&~2f0PgsK<#2iw038PW8mBC{{g^a?*t@j8kb70 z=EC8B_=l0!GV#xn6JXr)S8Af~s6D`_7e-N_V?d%(~b4 zU|*zYU!(_;76*3p2@ZW?ysiACwpE_;?k320)fwWzPt5N#&JR8He)@EwP%U%w7A|iF z0v+3295eP3$z4gKoR3ZvbX$do(bf%v^YixQZx`*%?pQuY(_Z_8Pblf}larDr!TSv- zi%fJ8qFk+Pu-lXB&4yjhE>hr?#Gu>qLj(j_d40R?;qR)<(V}`u*X%z4Qgn305F^2) z3)5U8ww-k^*$1MtQhyf-^%f-~8#KD;G&wqCE0$;GEqobOPG~ADDIti<+Vp+a+-{`H z*dg?%CW&1z$le=_4Fz$lrBk;5E?V>4bvuFi%08Z)i$1kGd4>mIsS4LE;Ow+zVHYIu zY2o@2%x9>A8{^BO3Gk1osu4%P=O9W7m%UAT- z>bpJ0CqhxD(Y8{|C!`Mo>oe2B>88F%f59z#OkCVJzRkpab{Jy|$A!5n+a;3DGoC2E zQuJNnumOXa#Rs|P7yQG3EGR=VEtwXto>8nNrl8Man^m8!knQUc2#0*)*#l565*qAjg98gfy z&V|eEOoQZ(gr2IQOWu#?i~e4%+^;wkZT$Gn@-fW5drhv`Ku4ig{w5Q}!hj-%G`n~5 z=Gc5%^cvOJzT%cgeGp}#II*8$S?8KrovtBs@ARiJNR4nMv^8u9ofs=>^!CNvorj4U z|A4e!%-^bf-GWwWqkdfCzTke!5sBavQniHMybw;E!?>EgBJ#oXVY|?Nugcl@jE4@z zhC&geLrgfU1Hf)$ocb-#3=O6^skQ$#J~5m z3*cK(hX-K6__E=_bkwO3 zs(-H>4)WVurAT!eZ6)Rd2mRfD%LxVc#Q{K866^QlGx>J3o@w#lkMs~fg*S8KEW^V5 nH6Q^$qVq2Wh> diff --git a/docs/index.md b/docs/index.md index ddde1e53e2..7e1cd59b19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,5 @@ # Welcome to Key Mapper Documentation -!!! warning "MAINTENANCE NOTICE!" - - Feature development has slowed down. - [Key Mapper](https://github.com/keymapperorg/KeyMapper) is a free and open source Android app that can map single or multiple buttons to a custom action. ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/keymapperorg/KeyMapper.svg) @@ -41,7 +37,6 @@ This wiki aims to provide users with a comprehensive guide to using and setting ### Community moderators/support - [Jack Ambler (jambl3r)](https://linkedin.com/in/jambl3r) -- [GL513](https://gl513.github.io/) ### Translators diff --git a/docs/user-guide/keymaps.md b/docs/user-guide/keymaps.md index cbb140082d..a1ee442434 100644 --- a/docs/user-guide/keymaps.md +++ b/docs/user-guide/keymaps.md @@ -69,37 +69,36 @@ type above the trigger mode buttons as shown in the image at the top of this Tri These triggers can be purchased so that the Key Mapper project has a little financial support and the developer is able to invest time maintaining and working on the project. You can see the list of advanced triggers below by tapping 'advanced triggers' on the trigger page. -![](../images/advanced-triggers-paywall.png) +![](../images/advanced-triggers-paywall.png){ width="200" } -### Assistant trigger +### Floating buttons -This trigger allows you to remap the various ways that your devices trigger the 'assistant' such as Google Assistant, Bixby, Alexa etc. +Read about floating buttons [here](floating-buttons.md). -There are 3 assistant options you can choose: +### Side key & Assistant trigger -- **Device assistant**: This is the assistant usually triggered from a long press of a power button or a dedicated button. -- **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. -- **Any assistant**: This will trigger the key map when any of the above are triggered. +This trigger allows you to remap the various ways that your device can trigger the side key or assistant. -!!! note - It is not possible to create long-press key maps with this trigger! But you can do double press. You also can not use multiple assistant triggers in a parallel trigger because there is no way to detect them being pressed at exactly the same time. +!!! danger + Key Mapper can not walk you through setting this trigger up because the steps are unique to every device and Android skin. **You must read the instructions below.** -### Setting up +There are 2 options you can choose to change how the trigger works. -There are multiple ways of triggering the assistant on different devices. +- **Side key/power button**: The key map will be triggered whenever the "Key Mapper: Side key" app is launched. Read more in the next section on how to set this up on some devices. +- **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. -**Long press power button, Pixel squeeze** +!!! note + It is not possible to create long-press key maps with this trigger! But you can do double press. You also can not use multiple assistant triggers in a parallel trigger because there is no way to detect them being pressed at exactly the same time. -This works on most Android devices. Android devices now have the option for remapping a long press of the power button to the assistant app. Older Pixels, such as the Pixel 2, also had a feature called "Active Edge" that allowed you to _squeeze_ the bottom half of the phone to trigger the assistant. If you select Key Mapper as the 'device assistant' app then your key map will be triggered with both of these methods. +**Long press power button, Pixel squeeze** -You can set up the long-press of the power button by going to Android Settings :material-arrow-right-thin: System :material-arrow-right-thin: Gestures :material-arrow-right-thin: Press and hold power button. Then choose the digital assistant instead of showing power menu when you long press the power button. Key Mapper will prompt you to select it as the default digital assistant app. +Most stock Android devices have this option. In your settings app you should have the option to remap a long press of the power button to the assistant app. Older Pixels, such as the Pixel 2, also have a feature called "Active Edge" that allowed you to _squeeze_ the bottom half of the phone to trigger the assistant. If you select Key Mapper as the default 'digital assistant' app then your key map will be triggered with both of these methods. -**Bixby button** +You can set up the long-press of the power button by going to Android Settings :material-arrow-right-thin: System :material-arrow-right-thin: Gestures :material-arrow-right-thin: Press and hold power button. Then choose the digital assistant instead of showing the power menu when you long press the power button. -This *should* work on Samsung devices that have a dedicated Bixby button but also devices that have the option of remapping the power button to another app when you double press it. You can use the assistant trigger for double pressing the Bixby or power button by picking the 'Assistant trigger' app/activity that shows in your list of apps. +**Samsung side key/Bixby button** -!!! note - The developer does not have a Samsung device with a Bixby button so there is no guarantee that it works. If it does, please let the developer know so we can be more confident about support in the future 😄. +This *should* work on Samsung devices that have a dedicated side key button but also devices that have the option of remapping the power button to another app when you long or double press it. You can use the assistant trigger for double pressing the Bixby or power button by picking the 'Key Mapper: Side key' app that shows in your list of apps. **Voice assistant button on keyboards and Bluetooth headphones** From d2b07d5c664145209392248a80ec1747629c7c65 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 15:24:35 -0600 Subject: [PATCH 77/94] docs: update assistant trigger documentation --- docs/user-guide/keymaps.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/keymaps.md b/docs/user-guide/keymaps.md index a1ee442434..a2730d9900 100644 --- a/docs/user-guide/keymaps.md +++ b/docs/user-guide/keymaps.md @@ -80,12 +80,12 @@ Read about floating buttons [here](floating-buttons.md). This trigger allows you to remap the various ways that your device can trigger the side key or assistant. !!! danger - Key Mapper can not walk you through setting this trigger up because the steps are unique to every device and Android skin. **You must read the instructions below.** + Key Mapper can not walk you through setting this trigger up because the steps are unique to almost every device and Android skin. **You must read the instructions below.** There are 2 options you can choose to change how the trigger works. - **Side key/power button**: The key map will be triggered whenever the "Key Mapper: Side key" app is launched. Read more in the next section on how to set this up on some devices. -- **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. +- **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. When you launch the voice assistant, your device should ask you which app to use by default and you should see Key Mapper in that list. !!! note It is not possible to create long-press key maps with this trigger! But you can do double press. You also can not use multiple assistant triggers in a parallel trigger because there is no way to detect them being pressed at exactly the same time. @@ -107,6 +107,11 @@ Many external devices such as headsets and keyboards have a button for launching !!! warning Some headphones have hardcoded the assistant apps that they support and will not work with Key Mapper. The developer has Sony WH1000XM3 headphones that only support Alexa and Google Assistant and refuse to launch Key Mapper when it is selected as the default assistant app. +**Help 😱! None of these steps work on my device** + +!!! question + You will need to search in your device settings or on the internet for how to remap the side key/power button on your device. If you're still stuck you can contact the developer in the app with the 'Contact developer' button shown in [this](keymaps.md#advanced-triggers) screenshot. _You **must** fill in the template otherwise we will ignore the email._ + ## Customising actions You can tap the pencil icon :material-pencil-outline: to the right of the action's name to bring up the following menu. From a6b61623d1629f71553c546d0be6916bf9cf58b2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 15:47:14 -0600 Subject: [PATCH 78/94] feat: Make it clearer that the instructions need to be read for the assistant trigger. --- CHANGELOG.md | 1 + .../keymaps/trigger/AssistantTriggerSetupBottomSheet.kt | 9 +++++++++ .../keymapper/mappings/keymaps/trigger/TriggerScreen.kt | 2 ++ app/src/main/res/values/strings.xml | 6 ++++++ docs/user-guide/keymaps.md | 2 +- mkdocs.yml | 1 + 6 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0add2838a4..211f5fe6fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ _See the changes from previous 3.0 Beta releases as well._ - #320 🗂️ Key map groups! You can now sort key maps into groups and share constraints across all the key maps in the group. - #1586 🎨 Customise floating button border and background opacity. - #1276 Use key event scan code as fallback if the key code is unrecognized. +- Make it clearer that the instructions need to be read for the assistant trigger. ## Changed diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt new file mode 100644 index 0000000000..3730a02096 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerSetupBottomSheet.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import androidx.compose.runtime.Composable + +@Composable +fun HandleAssistantTriggerSetupBottomSheet( + viewModel: ConfigTriggerViewModel, +) { +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt index dfa2911898..c56c550109 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt @@ -56,6 +56,8 @@ fun TriggerScreen(modifier: Modifier = Modifier, viewModel: ConfigTriggerViewMod val setupGuiKeyboardState by viewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() val recordTriggerState by viewModel.recordTriggerState.collectAsStateWithLifecycle() + HandleAssistantTriggerSetupBottomSheet(viewModel = viewModel) + if (viewModel.showAdvancedTriggersBottomSheet) { AdvancedTriggersBottomSheet( modifier = Modifier.systemBarsPadding(), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e219f3d8d3..b77db5e27c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -323,6 +323,7 @@ https://play.google.com/store/apps/details?id=io.github.sds100.keymapper https://keymapperorg.github.io/KeyMapper https://keymapperorg.github.io/KeyMapper/redirects/advanced-triggers + https://keymapperorg.github.io/KeyMapper/redirects/assistant-trigger https://keymapperorg.github.io/KeyMapper/redirects/floating-buttons https://keymapperorg.github.io/KeyMapper/redirects/floating-layouts https://keymapperorg.github.io/KeyMapper/redirects/floating-button-config @@ -1194,6 +1195,11 @@ You must purchase floating buttons. Button was deleted. Floating button + Set up side key trigger + Attention! + You must read the instructions on our website that describe how to set up this trigger. Key Mapper will not guide you. + Read instructions + Select trigger type Unlock (%s) diff --git a/docs/user-guide/keymaps.md b/docs/user-guide/keymaps.md index a2730d9900..25ae4e0ebb 100644 --- a/docs/user-guide/keymaps.md +++ b/docs/user-guide/keymaps.md @@ -82,7 +82,7 @@ This trigger allows you to remap the various ways that your device can trigger t !!! danger Key Mapper can not walk you through setting this trigger up because the steps are unique to almost every device and Android skin. **You must read the instructions below.** -There are 2 options you can choose to change how the trigger works. +There are 2 options you can choose to configure how the trigger works. - **Side key/power button**: The key map will be triggered whenever the "Key Mapper: Side key" app is launched. Read more in the next section on how to set this up on some devices. - **Voice assistant**: This is the assistant launched from the hands-free voice button on keyboards/headsets. When you launch the voice assistant, your device should ask you which app to use by default and you should see Key Mapper in that list. diff --git a/mkdocs.yml b/mkdocs.yml index c8424fd01e..48371a5632 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ plugins: 'redirects/floating-buttons.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/floating-buttons' 'redirects/floating-layouts.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/floating-buttons' 'redirects/floating-button-config.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/floating-buttons' + 'redirects/assistant-trigger.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#side-key-assistant-trigger' - search: lang: - en From 9f60e18efb1686a9e120b5d31d059826ad9c1601 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 15:58:59 -0600 Subject: [PATCH 79/94] fix tests --- .../java/io/github/sds100/keymapper/backup/BackupManager.kt | 3 +++ .../mappings/keymaps/ProcessKeyMapGroupsForDetectionTest.kt | 1 + 2 files changed, 4 insertions(+) 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 e3d9bfdfd8..4fa8955aad 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 @@ -253,6 +253,9 @@ class BackupManagerImpl( // Do nothing just added nullable group uid column JsonMigration(15, 16) { json -> json }, + + // Do nothing just added nullable column for when a group was last opened + JsonMigration(16, 17) { json -> json }, ) if (keyMapListJsonArray != null) { 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 index 1dda70b7f4..2f093a3899 100644 --- 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 @@ -290,6 +290,7 @@ class ProcessKeyMapGroupsForDetectionTest { mode = mode, ), parentUid = parentUid, + lastOpenedDate = 0, ) } } From 7fcaf9bb6cc2cdfb49b87315ecda81c6e72cfe11 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 16:04:07 -0600 Subject: [PATCH 80/94] fix: do not wrap group placeholders --- .../main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt | 1 + 1 file changed, 1 insertion(+) 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 index 460e7fdcf7..8d8ee877a5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -643,6 +643,7 @@ private fun GroupNameRow( Text( placeholder, style = MaterialTheme.typography.titleLarge, + maxLines = 1, ) }, innerTextField = { From 9f8c0e6a477f9b1bc49628617a972a8aee976ca6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 17:21:53 -0600 Subject: [PATCH 81/94] delete group name unique constraint --- .../18.json | 398 ++++++++++++++++++ .../github/sds100/keymapper/ServiceLocator.kt | 1 + .../sds100/keymapper/data/db/AppDatabase.kt | 8 +- .../keymapper/data/entities/GroupEntity.kt | 2 - .../AutoMigration16To17.kt | 2 +- .../data/migration/AutoMigration17To18.kt | 5 + 6 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json rename app/src/main/java/io/github/sds100/keymapper/data/migration/{fingerprintmaps => }/AutoMigration16To17.kt (59%) create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json new file mode 100644 index 0000000000..0fb4041f1a --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/18.json @@ -0,0 +1,398 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "d6bb60215344b4b6c1a49f72576b6535", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, `last_opened_date` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd6bb60215344b4b6c1a49f72576b6535')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt index 7404ed2235..00f14520f2 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -298,6 +298,7 @@ object ServiceLocator { AppDatabase.RoomMigration11To12(context.applicationContext.legacyFingerprintMapDataStore), AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_13_14, + AppDatabase.MIGRATION_17_18, ).build() private val Context.legacyFingerprintMapDataStore by preferencesDataStore("fingerprint_gestures") diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 9b2a68826f..e3a933e747 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -27,6 +27,7 @@ import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.data.migration.AutoMigration14To15 import io.github.sds100.keymapper.data.migration.AutoMigration15To16 +import io.github.sds100.keymapper.data.migration.AutoMigration16To17 import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -38,7 +39,6 @@ import io.github.sds100.keymapper.data.migration.Migration5To6 import io.github.sds100.keymapper.data.migration.Migration6To7 import io.github.sds100.keymapper.data.migration.Migration8To9 import io.github.sds100.keymapper.data.migration.Migration9To10 -import io.github.sds100.keymapper.data.migration.fingerprintmaps.AutoMigration16To17 /** * Created by sds100 on 24/01/2020. @@ -65,7 +65,7 @@ import io.github.sds100.keymapper.data.migration.fingerprintmaps.AutoMigration16 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 17 + const val DATABASE_VERSION = 18 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -141,9 +141,9 @@ abstract class AppDatabase : RoomDatabase() { } } - val MIGRATION_14_15 = object : Migration(14, 15) { + val MIGRATION_17_18 = object : Migration(17, 18) { override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("") + database.execSQL("DROP INDEX IF EXISTS `index_groups_name`") } } } 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 index f1d9823476..18e97c7f66 100644 --- 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 @@ -4,7 +4,6 @@ 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 @@ -19,7 +18,6 @@ import java.util.UUID @Entity( tableName = GroupDao.TABLE_NAME, - indices = [Index(value = [GroupDao.KEY_NAME], unique = true)], foreignKeys = [ ForeignKey( entity = GroupEntity::class, diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration16To17.kt similarity index 59% rename from app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt rename to app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration16To17.kt index 4ff7d3a310..ce92b9a3a9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/fingerprintmaps/AutoMigration16To17.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration16To17.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.data.migration.fingerprintmaps +package io.github.sds100.keymapper.data.migration import androidx.room.migration.AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt new file mode 100644 index 0000000000..1fedac8e2c --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration17To18 : AutoMigrationSpec From f7d07e3e622665cad4e0c0261a9365f468219b64 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 17:59:21 -0600 Subject: [PATCH 82/94] allow groups with the same name if they have different parents --- .../sds100/keymapper/backup/BackupManager.kt | 19 +++++-- .../data/repositories/RepositoryUtils.kt | 4 +- .../mappings/keymaps/KeyMapListViewModel.kt | 2 + .../mappings/keymaps/ListKeyMapsUseCase.kt | 49 +++++++++++-------- 4 files changed, 47 insertions(+), 27 deletions(-) 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 4fa8955aad..d1fb61675f 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 @@ -460,7 +460,7 @@ class BackupManagerImpl( if (backupContent.groups != null) { val groupUids = backupContent.groups.map { it.uid }.toMutableSet() - groupRepository.getAllGroups().first() + val existingGroupUids = groupRepository.getAllGroups().first() .map { it.uid } .toSet() .also { groupUids.addAll(it) } @@ -478,13 +478,26 @@ class BackupManagerImpl( modifiedGroup = modifiedGroup.copy(parentUid = null) } - RepositoryUtils.saveUniqueName( + val siblings = + groupRepository.getGroupsByParent(modifiedGroup.parentUid).first() + + modifiedGroup = RepositoryUtils.saveUniqueName( modifiedGroup, - saveBlock = { groupRepository.insert(it) }, + saveBlock = { renamedGroup -> + if (siblings.any { sibling -> sibling.name == renamedGroup.name }) { + throw IllegalStateException("Non unique group name") + } + }, renameBlock = { entity, suffix -> entity.copy(name = "${entity.name} $suffix") }, ) + + if (existingGroupUids.contains(modifiedGroup.uid)) { + groupRepository.update(modifiedGroup) + } else { + groupRepository.insert(modifiedGroup) + } } } 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 index 89c6f6d83d..50ad468cc5 100644 --- 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 @@ -1,7 +1,5 @@ package io.github.sds100.keymapper.data.repositories -import android.database.sqlite.SQLiteConstraintException - object RepositoryUtils { suspend fun saveUniqueName( entity: T, @@ -17,7 +15,7 @@ object RepositoryUtils { try { saveBlock(group) break - } catch (_: SQLiteConstraintException) { + } catch (_: Exception) { // If the name already exists try creating it with a new name. group = renameBlock(entity, "(${count + 1})") count++ 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 6e22647620..b8b6fcb1b0 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 @@ -761,6 +761,7 @@ class KeyMapListViewModel( fun onGroupClick(uid: String?) { coroutineScope.launch { + isEditingGroupName.update { false } isNewGroup = false listKeyMaps.openGroup(uid) } @@ -768,6 +769,7 @@ class KeyMapListViewModel( fun onDeleteGroupClick() { coroutineScope.launch { + isEditingGroupName.update { false } listKeyMaps.deleteGroup() } } 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 d0012d5402..fa0f94b206 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 @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -49,6 +50,10 @@ class ListKeyMapsUseCaseImpl( ) : ListKeyMapsUseCase, DisplayKeyMapUseCase by displayKeyMapUseCase { private val groupUid = MutableStateFlow(null) + + /** + * These are the parents, grandparents etc of the current group. + */ private val parentGroupUids = MutableStateFlow>(emptyList()) @OptIn(ExperimentalCoroutinesApi::class) @@ -73,16 +78,14 @@ class ListKeyMapsUseCaseImpl( @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]!!) } - } + parentGroupUids.flatMapLatest { parentUids -> + groupRepository.getGroups(*parentUids.toTypedArray()).map { groups -> + // The repository returns the objects unordered so order them by the + // original UID list again. + val mapped = groups.associateBy { it.uid } + parentUids.map { GroupEntityMapper.fromEntity(mapped[it]!!) } } + } @OptIn(ExperimentalCoroutinesApi::class) override val keyMapGroup: Flow = channelFlow { @@ -107,15 +110,14 @@ class ListKeyMapsUseCaseImpl( override suspend fun newGroup() { val defaultName = resourceProvider.getString(R.string.default_group_name) - val group = GroupEntity( + var group = GroupEntity( parentUid = groupUid.value, name = defaultName, lastOpenedDate = System.currentTimeMillis(), ) - ensureUniqueName(group) { - groupRepository.insert(it) - } + group = ensureUniqueName(group) + groupRepository.insert(group) groupUid.update { group.uid } parentGroupUids.update { it.plus(group.uid) } @@ -143,23 +145,28 @@ class ListKeyMapsUseCaseImpl( entity = entity.copy(name = name.trim()) - try { - groupRepository.update(entity) - } catch (_: SQLiteConstraintException) { + val siblings = groupRepository.getGroupsByParent(entity.parentUid).first() + + if (siblings.any { it.name == entity.name }) { return false } + + groupRepository.update(entity) } return true } - private suspend fun ensureUniqueName( - group: GroupEntity, - block: suspend (entity: GroupEntity) -> Unit, - ): GroupEntity { + private suspend fun ensureUniqueName(group: GroupEntity): GroupEntity { + val siblings = groupRepository.getGroupsByParent(group.parentUid).first() + return RepositoryUtils.saveUniqueName( entity = group, - saveBlock = block, + saveBlock = { renamedGroup -> + if (siblings.any { sibling -> sibling.name == renamedGroup.name }) { + throw IllegalStateException("Non unique group name") + } + }, renameBlock = { entity, suffix -> entity.copy(name = "${entity.name} $suffix") }, From e4ec49513840ffb2ec5bd7782f278996e5168e39 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 19:32:31 -0600 Subject: [PATCH 83/94] feat: groups in the selection bottom sheet are fully navigable --- .../sds100/keymapper/data/db/dao/GroupDao.kt | 4 +- ...ubGroups.kt => GroupEntityWithChildren.kt} | 4 +- .../data/repositories/GroupRepository.kt | 6 +- .../sds100/keymapper/groups/GroupFamily.kt | 7 + .../sds100/keymapper/groups/GroupRow.kt | 18 +- .../keymapper/groups/GroupWithSubGroups.kt | 6 - .../keymapper/home/HomeKeyMapListScreen.kt | 10 +- .../sds100/keymapper/home/KeyMapAppBar.kt | 64 +++---- .../keymapper/home/SelectionBottomSheet.kt | 32 +++- .../keymaps/CreateKeyMapShortcutViewModel.kt | 2 +- .../mappings/keymaps/KeyMapAppBarState.kt | 4 +- .../mappings/keymaps/KeyMapListViewModel.kt | 105 ++++++----- .../mappings/keymaps/ListKeyMapsUseCase.kt | 178 +++++++++++------- app/src/main/res/values/strings.xml | 1 + 14 files changed, 270 insertions(+), 171 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/data/entities/{GroupEntityWithSubGroups.kt => GroupEntityWithChildren.kt} (81%) create mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt delete mode 100644 app/src/main/java/io/github/sds100/keymapper/groups/GroupWithSubGroups.kt 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 index b5f0c15eb6..b463a63f37 100644 --- 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 @@ -7,7 +7,7 @@ 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.GroupEntityWithChildren import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup import kotlinx.coroutines.flow.Flow @@ -39,7 +39,7 @@ interface GroupDao { fun getByIdFlow(uid: String): Flow @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") - fun getGroupWithSubGroups(uid: String): Flow + fun getGroupWithSubGroups(uid: String): Flow @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_PARENT_UID IS (:uid)") fun getGroupsByParent(uid: String?): Flow> 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/GroupEntityWithChildren.kt similarity index 81% rename from app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithSubGroups.kt rename to app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithChildren.kt index df226234be..ecedbaa99b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithSubGroups.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntityWithChildren.kt @@ -4,7 +4,7 @@ import androidx.room.Embedded import androidx.room.Relation import io.github.sds100.keymapper.data.db.dao.GroupDao -data class GroupEntityWithSubGroups( +data class GroupEntityWithChildren( @Embedded val group: GroupEntity, @@ -12,5 +12,5 @@ data class GroupEntityWithSubGroups( parentColumn = GroupDao.KEY_UID, entityColumn = GroupDao.KEY_PARENT_UID, ) - val subGroups: List, + val children: List, ) 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 index ce6befe960..23423e11b8 100644 --- 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 @@ -2,7 +2,7 @@ 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.GroupEntityWithChildren import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup import io.github.sds100.keymapper.util.DefaultDispatcherProvider import io.github.sds100.keymapper.util.DispatcherProvider @@ -23,7 +23,7 @@ interface GroupRepository { fun getAllGroups(): Flow> fun getGroups(vararg uid: String): Flow> fun getGroupsByParent(uid: String?): Flow> - fun getGroupWithSubGroups(uid: String): Flow + fun getGroupWithChildren(uid: String): Flow suspend fun insert(groupEntity: GroupEntity) suspend fun update(groupEntity: GroupEntity) fun delete(uid: String) @@ -59,7 +59,7 @@ class RoomGroupRepository( return dao.getGroupsByParent(uid).flowOn(dispatchers.io()) } - override fun getGroupWithSubGroups(uid: String): Flow { + override fun getGroupWithChildren(uid: String): Flow { return dao.getGroupWithSubGroups(uid).flowOn(dispatchers.io()) } diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt new file mode 100644 index 0000000000..e6e7a790e4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupFamily.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.groups + +data class GroupFamily( + val group: Group?, + val children: List, + val parents: List, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt index 1cae6fa27a..487576f491 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -50,6 +50,8 @@ fun GroupRow( onGroupClick: (String) -> Unit = {}, enabled: Boolean = true, isSubgroups: Boolean = false, + showThisGroupButton: Boolean = false, + onThisGroupClick: () -> Unit = {}, ) { var viewAllState by rememberSaveable { mutableStateOf(false) } @@ -67,7 +69,7 @@ fun GroupRow( overflow = FlowRowOverflow.expandOrCollapseIndicator( expandIndicator = { // Some padding is required on the end to stop it overflowing the screen. - ViewAllButton( + TextGroupButton( modifier = Modifier.padding(end = 16.dp), onClick = { viewAllState = true }, text = stringResource(R.string.home_new_view_all_groups_button), @@ -75,7 +77,7 @@ fun GroupRow( ) }, collapseIndicator = { - ViewAllButton( + TextGroupButton( modifier = Modifier.padding(end = 16.dp), onClick = { viewAllState = false }, text = stringResource(R.string.home_new_hide_groups_button), @@ -99,6 +101,14 @@ fun GroupRow( enabled = enabled, ) + if (showThisGroupButton) { + TextGroupButton( + onClick = onThisGroupClick, + text = stringResource(R.string.home_this_group_button), + enabled = enabled, + ) + } + for (group in groups) { GroupButton( onClick = { onGroupClick(group.uid) }, @@ -180,7 +190,7 @@ private fun NewGroupButton( } @Composable -private fun ViewAllButton( +private fun TextGroupButton( modifier: Modifier = Modifier, onClick: () -> Unit, text: String, @@ -287,6 +297,7 @@ private fun PreviewOneItem() { ), ), enabled = false, + showThisGroupButton = false, ) } } @@ -337,6 +348,7 @@ private fun PreviewMultipleItems() { icon = null, ), ), + showThisGroupButton = true, ) } } 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 deleted file mode 100644 index 407dc502b6..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupWithSubGroups.kt +++ /dev/null @@ -1,6 +0,0 @@ -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/HomeKeyMapListScreen.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt index 1ceb1ba9c1..453c9caed9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeKeyMapListScreen.kt @@ -216,6 +216,8 @@ fun HomeKeyMapListScreen( selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, isAllSelected = false, groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, ) SelectionBottomSheet( @@ -225,13 +227,16 @@ fun HomeKeyMapListScreen( }, enabled = selectionState.selectionCount > 0, groups = selectionState.groups, + breadcrumbs = selectionState.breadcrumbs, selectedKeyMapsEnabled = selectionState.selectedKeyMapsEnabled, onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, onExportClick = viewModel::onExportSelectedKeyMaps, onDeleteClick = { showDeleteDialog = true }, - onMoveToGroupClick = viewModel::onMoveToGroupClick, + onGroupClick = viewModel::onSelectionGroupClick, onNewGroupClick = viewModel::onNewGroupClick, + showThisGroup = selectionState.showThisGroup, + onThisGroupClick = viewModel::onMoveToThisGroupClick, ) } }, @@ -476,6 +481,8 @@ private fun PreviewSelectingKeyMaps() { selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, isAllSelected = false, groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, ) val listState = State.Data(sampleList()) @@ -499,6 +506,7 @@ private fun PreviewSelectingKeyMaps() { enabled = true, selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, groups = emptyList(), + breadcrumbs = emptyList(), ) }, ) 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 index 8d8ee877a5..d02824160c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -228,7 +228,7 @@ fun KeyMapAppBar( onEditClick = onEditGroupNameClick, isEditingGroupName = state.isEditingGroupName, subGroups = state.subGroups, - parentGroups = state.parentGroups, + parentGroups = state.breadcrumbs, onGroupClick = onGroupClick, constraints = state.constraints, constraintMode = state.constraintMode, @@ -415,12 +415,6 @@ private fun ChildGroupAppBar( } 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) @@ -462,17 +456,6 @@ private fun ChildGroupAppBar( Surface { Column { - GroupRow( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), - groups = subGroups, - onNewGroupClick = onNewGroupClick, - onGroupClick = onGroupClick, - enabled = !isEditingGroupName, - isSubgroups = true, - ) - val scrollState = rememberScrollState() LaunchedEffect(parentGroups) { @@ -483,10 +466,21 @@ private fun ChildGroupAppBar( modifier = Modifier .horizontalScroll(scrollState) .fillMaxWidth() - .padding(horizontal = 8.dp), + .padding(8.dp), groups = parentGroups, onGroupClick = onGroupClick, ) + + GroupRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + groups = subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + enabled = !isEditingGroupName, + isSubgroups = true, + ) } } } @@ -644,6 +638,7 @@ private fun GroupNameRow( placeholder, style = MaterialTheme.typography.titleLarge, maxLines = 1, + color = OutlinedTextFieldDefaults.colors().disabledPlaceholderColor, ) }, innerTextField = { @@ -911,7 +906,7 @@ private fun KeyMapsChildGroupPreview() { subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, - parentGroups = groupSampleList(), + breadcrumbs = groupSampleList(), isEditingGroupName = false, isNewGroup = false, ) @@ -929,7 +924,7 @@ private fun KeyMapsChildGroupDarkPreview() { subGroups = groupSampleList(), constraints = emptyList(), constraintMode = ConstraintMode.AND, - parentGroups = emptyList(), + breadcrumbs = emptyList(), isEditingGroupName = false, isNewGroup = false, ) @@ -942,16 +937,6 @@ private fun KeyMapsChildGroupDarkPreview() { @Preview(showSystemUi = true) @Composable private fun KeyMapsChildGroupEditingPreview() { - val state = KeyMapAppBarState.ChildGroup( - groupName = "Untitled group 23", - subGroups = groupSampleList(), - constraints = constraintsSampleList(), - constraintMode = ConstraintMode.AND, - parentGroups = emptyList(), - isEditingGroupName = true, - isNewGroup = true, - ) - val focusRequester = FocusRequester() LaunchedEffect("") { @@ -959,8 +944,15 @@ private fun KeyMapsChildGroupEditingPreview() { } KeyMapperTheme { - KeyMapAppBar( - state = state, + ChildGroupAppBar( + groupName = TextFieldValue(""), + placeholder = "Untitled group 23", + error = stringResource(R.string.home_app_bar_group_name_unique_error), + isEditingGroupName = true, + subGroups = emptyList(), + parentGroups = emptyList(), + constraints = emptyList(), + constraintMode = ConstraintMode.AND, ) } } @@ -974,7 +966,7 @@ private fun KeyMapsChildGroupEditingDarkPreview() { subGroups = groupSampleList(), constraints = constraintsSampleList(), constraintMode = ConstraintMode.AND, - parentGroups = emptyList(), + breadcrumbs = emptyList(), isEditingGroupName = true, isNewGroup = true, ) @@ -1104,6 +1096,8 @@ private fun HomeStateSelectingPreview() { selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, isAllSelected = false, groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, ) KeyMapperTheme { KeyMapAppBar(state = state) @@ -1119,6 +1113,8 @@ private fun HomeStateSelectingDisabledPreview() { selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED, isAllSelected = true, groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, ) KeyMapperTheme { KeyMapAppBar(state = state) diff --git a/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt index c146213915..c78a40dac2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/SelectionBottomSheet.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.groups.GroupBreadcrumbRow import io.github.sds100.keymapper.groups.GroupListItemModel import io.github.sds100.keymapper.groups.GroupRow import io.github.sds100.keymapper.util.drawable @@ -50,15 +51,18 @@ import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo @Composable fun SelectionBottomSheet( modifier: Modifier = Modifier, - groups: List, enabled: Boolean, + groups: List, + breadcrumbs: List, selectedKeyMapsEnabled: SelectedKeyMapsEnabled, onDuplicateClick: () -> Unit = {}, onDeleteClick: () -> Unit = {}, onExportClick: () -> Unit = {}, onEnabledKeyMapsChange: (Boolean) -> Unit = {}, onNewGroupClick: () -> Unit = {}, - onMoveToGroupClick: (String) -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, + showThisGroup: Boolean = false, + onThisGroupClick: () -> Unit = {}, ) { Surface( modifier = modifier @@ -132,15 +136,27 @@ fun SelectionBottomSheet( style = MaterialTheme.typography.labelLarge, ) + Spacer(modifier = Modifier.height(8.dp)) + + GroupBreadcrumbRow( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp), + groups = breadcrumbs, + onGroupClick = onGroupClick, + enabled = true, + ) + GroupRow( modifier = Modifier - .padding(horizontal = 16.dp, vertical = 4.dp) + .padding(horizontal = 16.dp) .fillMaxWidth(), groups = groups, onNewGroupClick = onNewGroupClick, - onGroupClick = onMoveToGroupClick, + onGroupClick = onGroupClick, enabled = enabled, + showThisGroupButton = showThisGroup, + onThisGroupClick = onThisGroupClick, ) + Spacer(modifier = Modifier.height(4.dp)) } } } @@ -225,6 +241,7 @@ private fun PreviewEmptyGroups() { SelectionBottomSheet( enabled = true, groups = emptyList(), + breadcrumbs = emptyList(), selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL, onDuplicateClick = {}, onDeleteClick = {}, @@ -284,6 +301,13 @@ private fun PreviewGroups() { icon = null, ), ), + breadcrumbs = listOf( + GroupListItemModel( + uid = "2", + name = "Key Mapper", + icon = null, + ), + ), selectedKeyMapsEnabled = SelectedKeyMapsEnabled.ALL, onDuplicateClick = {}, onDeleteClick = {}, 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 9f9e4b261f..c1086bed67 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 @@ -142,7 +142,7 @@ class CreateKeyMapShortcutViewModel( subGroups = subGroupListItems, constraints = emptyList(), constraintMode = ConstraintMode.AND, - parentGroups = parentGroupListItems, + breadcrumbs = parentGroupListItems, isEditingGroupName = false, isNewGroup = false, ) 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 index c40507a95f..eab57c3e23 100644 --- 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 @@ -18,7 +18,7 @@ sealed class KeyMapAppBarState { val constraints: List, val constraintMode: ConstraintMode, val subGroups: List, - val parentGroups: List, + val breadcrumbs: List, val isEditingGroupName: Boolean, val isNewGroup: Boolean, ) : KeyMapAppBarState() @@ -28,5 +28,7 @@ sealed class KeyMapAppBarState { val selectedKeyMapsEnabled: SelectedKeyMapsEnabled, val isAllSelected: Boolean, val groups: List, + val breadcrumbs: List, + val showThisGroup: Boolean, ) : KeyMapAppBarState() } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt index b8b6fcb1b0..da5928c631 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 @@ -12,6 +12,7 @@ import io.github.sds100.keymapper.constraints.ConstraintErrorSnapshot import io.github.sds100.keymapper.constraints.ConstraintMode import io.github.sds100.keymapper.constraints.ConstraintUiHelper import io.github.sds100.keymapper.groups.Group +import io.github.sds100.keymapper.groups.GroupFamily import io.github.sds100.keymapper.groups.GroupListItemModel import io.github.sds100.keymapper.home.HomeWarningListItem import io.github.sds100.keymapper.home.SelectedKeyMapsEnabled @@ -71,6 +72,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@OptIn(ExperimentalCoroutinesApi::class) class KeyMapListViewModel( private val coroutineScope: CoroutineScope, private val listKeyMaps: ListKeyMapsUseCase, @@ -90,8 +92,6 @@ class KeyMapListViewModel( 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) @@ -243,39 +243,45 @@ class KeyMapListViewModel( } } - val homeGroupListItem = GroupListItemModel( - uid = HOME_GROUP_UID, - name = getString(R.string.home_groups_breadcrumb_home), - icon = null, + val selectionGroupFamilyStateFlow = listKeyMaps.selectionGroupFamily.stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5000), + GroupFamily(null, emptyList(), emptyList()), ) + val selectionBreadcrumbs: Flow> = + selectionGroupFamilyStateFlow.map { list -> + list.parents.map { GroupListItemModel(uid = it.uid, name = it.name, 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 selectionGroupListItems: Flow> = + selectionGroupFamilyStateFlow.map { family -> + family.children.map { + GroupListItemModel( + uid = it.uid, + name = it.name, + icon = null, + ) } - - val filteredGroups = groupList - .filter { it.uid != keyMapGroup.group?.uid } - .map(::buildGroupListItem) - - listItems.addAll(filteredGroups) - - listItems } + val showThisGroup = combine( + keyMapGroupStateFlow, + selectionGroupFamilyStateFlow, + ) { keyMapGroup, selectionGroup -> keyMapGroup.group?.uid != selectionGroup.group?.uid } + val selectionAppBarState = combine( multiSelectProvider.state.filterIsInstance(), keyMapGroupStateFlow, - groupListItems, - ) { selectionState, keyMapGroup, groups -> + selectionGroupListItems, + selectionBreadcrumbs, + showThisGroup, + ) { selectionState, keyMapGroup, groups, selectionBreadcrumbs, showThisGroup -> buildSelectingAppBarState( keyMapGroup, selectionState, groups, + selectionBreadcrumbs, + showThisGroup, ) } @@ -325,6 +331,8 @@ class KeyMapListViewModel( keyMapGroup: KeyMapGroup, selectionState: SelectionState.Selecting, groupListItems: List, + breadcrumbListItems: List, + showThisGroup: Boolean, ): KeyMapAppBarState.Selecting { var selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = null val keyMaps = keyMapGroup.keyMaps.dataOrNull() ?: emptyList() @@ -353,6 +361,8 @@ class KeyMapListViewModel( selectedKeyMapsEnabled = selectedKeyMapsEnabled ?: SelectedKeyMapsEnabled.NONE, isAllSelected = selectionState.selectedIds.size == keyMaps.size, groups = groupListItems, + breadcrumbs = breadcrumbListItems, + showThisGroup = showThisGroup, ) } @@ -390,7 +400,7 @@ class KeyMapListViewModel( ), constraintMode = keyMapGroup.group.constraintState.mode, subGroups = subGroupListItems, - parentGroups = parentGroupListItems, + breadcrumbs = parentGroupListItems, isEditingGroupName = isEditingGroupName, isNewGroup = isNewGroup, ) @@ -448,8 +458,11 @@ class KeyMapListViewModel( fun onKeyMapCardLongClick(uid: String) { if (multiSelectProvider.state.value is SelectionState.NotSelecting) { - multiSelectProvider.startSelecting() - multiSelectProvider.select(uid) + coroutineScope.launch { + val currentGroupUid = listKeyMaps.keyMapGroup.first().group?.uid + multiSelectProvider.startSelecting() + multiSelectProvider.select(uid) + } } } @@ -603,17 +616,19 @@ class KeyMapListViewModel( } } - fun onMoveToGroupClick(groupUid: String) { + fun onSelectionGroupClick(groupUid: String?) { + coroutineScope.launch { + listKeyMaps.openSelectionGroup(groupUid) + } + } + + fun onMoveToThisGroupClick() { val selectionState = multiSelectProvider.state.value if (selectionState !is SelectionState.Selecting) return val selectedIds = selectionState.selectedIds.toTypedArray() - if (groupUid == HOME_GROUP_UID) { - listKeyMaps.moveKeyMapsToGroup(null, *selectedIds) - } else { - listKeyMaps.moveKeyMapsToGroup(groupUid, *selectedIds) - } + listKeyMaps.moveKeyMapsToSelectedGroup(*selectedIds) multiSelectProvider.deselect(*selectedIds) multiSelectProvider.stopSelecting() @@ -712,6 +727,9 @@ class KeyMapListViewModel( _importExportState.value = ImportExportState.Idle } + /** + * @return whether the back was handled and the activity should not finish. + */ fun onBackClick(): Boolean { when { multiSelectProvider.state.value is SelectionState.Selecting -> { @@ -720,15 +738,7 @@ class KeyMapListViewModel( } state.value.appBarState is KeyMapAppBarState.ChildGroup -> { - if (isEditingGroupName.value) { - if (isNewGroup) { - coroutineScope.launch { - listKeyMaps.deleteGroup() - } - } else { - isEditingGroupName.update { false } - } - } else { + if (!isEditingGroupName.value) { coroutineScope.launch { listKeyMaps.popGroup() } @@ -736,6 +746,7 @@ class KeyMapListViewModel( isNewGroup = false isEditingGroupName.update { false } + return true } @@ -761,8 +772,8 @@ class KeyMapListViewModel( fun onGroupClick(uid: String?) { coroutineScope.launch { - isEditingGroupName.update { false } isNewGroup = false + isEditingGroupName.update { false } listKeyMaps.openGroup(uid) } } @@ -776,8 +787,16 @@ class KeyMapListViewModel( fun onNewGroupClick() { coroutineScope.launch { + when (val selectionState = multiSelectProvider.state.value) { + is SelectionState.Selecting -> + listKeyMaps.moveKeyMapsToNewGroup(*selectionState.selectedIds.toTypedArray()) + + SelectionState.NotSelecting -> { + listKeyMaps.newGroup() + } + } + multiSelectProvider.stopSelecting() - listKeyMaps.newGroup() isNewGroup = true isEditingGroupName.update { true } } 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 fa0f94b206..d526d2677e 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 @@ -15,7 +15,7 @@ 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.groups.GroupFamily import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.State @@ -35,10 +35,12 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import java.util.LinkedList /** * Created by sds100 on 16/04/2021. */ +@OptIn(ExperimentalCoroutinesApi::class) class ListKeyMapsUseCaseImpl( private val keyMapRepository: KeyMapRepository, private val groupRepository: GroupRepository, @@ -49,54 +51,48 @@ class ListKeyMapsUseCaseImpl( displayKeyMapUseCase: DisplayKeyMapUseCase, ) : ListKeyMapsUseCase, DisplayKeyMapUseCase by displayKeyMapUseCase { - private val groupUid = MutableStateFlow(null) + private val keyMapListGroupUid = MutableStateFlow(null) + private val selectionGroupUid = MutableStateFlow(null) - /** - * These are the parents, grandparents etc of the current group. - */ - private val parentGroupUids = MutableStateFlow>(emptyList()) + private fun setCurrentGroup(groupUid: String?) { + keyMapListGroupUid.update { groupUid } + selectionGroupUid.update { groupUid } + } - @OptIn(ExperimentalCoroutinesApi::class) - private val group: Flow = groupUid.flatMapLatest { groupUid -> + private suspend fun getGroupFamily(groupUid: String?): Flow { + // If the current group is the root then just get the subgroups. if (groupUid == null) { - groupRepository.getGroupsByParent(null).map { subGroupEntities -> - val subGroups = subGroupEntities + return groupRepository.getGroupsByParent(null).map { childrenEntities -> + val children = childrenEntities .map(GroupEntityMapper::fromEntity) .sortedByDescending { it.lastOpenedDate } - GroupWithSubGroups(group = null, subGroups = subGroups) + GroupFamily(group = null, children = children, parents = emptyList()) } } else { - groupRepository.getGroupWithSubGroups(groupUid).map { groupWithSubGroups -> - val group = GroupEntityMapper.fromEntity(groupWithSubGroups.group) - val subGroups = - groupWithSubGroups.subGroups.map(GroupEntityMapper::fromEntity) + val parents = getParentsRecursively(groupUid) - GroupWithSubGroups(group, subGroups) - } - } - } + return groupRepository.getGroupWithChildren(groupUid).map { groupWithChildren -> + val group = GroupEntityMapper.fromEntity(groupWithChildren.group) + val children = groupWithChildren.children.map(GroupEntityMapper::fromEntity) - @OptIn(ExperimentalCoroutinesApi::class) - private val parentGroups: Flow> = - parentGroupUids.flatMapLatest { parentUids -> - groupRepository.getGroups(*parentUids.toTypedArray()).map { groups -> - // The repository returns the objects unordered so order them by the - // original UID list again. - val mapped = groups.associateBy { it.uid } - parentUids.map { GroupEntityMapper.fromEntity(mapped[it]!!) } + GroupFamily(group, children = children, parents = parents) } } + } - @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) } + keyMapListGroupUid + .flatMapLatest(::getGroupFamily) + .map { groupFamily -> + val parentGroups = getParentsRecursively(groupFamily.group?.uid) + + KeyMapGroup( + group = groupFamily.group, + subGroups = groupFamily.children, + keyMaps = State.Loading, + parents = parentGroups, + ) + }.onEach { send(it) } .flatMapLatest { keyMapGroup -> getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } }.collect { @@ -104,33 +100,76 @@ class ListKeyMapsUseCaseImpl( } } - override fun getGroups(): Flow> { - return groupRepository.groups.map { list -> list.map(GroupEntityMapper::fromEntity) } + override val selectionGroupFamily: Flow = + selectionGroupUid.flatMapLatest(::getGroupFamily) + + override suspend fun openSelectionGroup(uid: String?) { + if (uid == null) { + // If null then open the root group. + selectionGroupUid.update { null } + } else { + // Check if the group exists. + val group = groupRepository.getGroup(uid) ?: return + selectionGroupUid.update { group.uid } + } + } + + private suspend fun getParentsRecursively(groupUid: String?): List { + val list = LinkedList() + var count = 0 + + var currentGroup: String? = groupUid + + while (count < 1000) { + if (currentGroup == null) { + break + } + + val group = groupRepository.getGroup(currentGroup) ?: break + list.addFirst(GroupEntityMapper.fromEntity(group)) + currentGroup = group.parentUid + + count++ + } + + return list + } + + override fun getGroups(parentUid: String?): Flow> { + return groupRepository.getGroupsByParent(parentUid) + .map { list -> list.map(GroupEntityMapper::fromEntity) } } override suspend fun newGroup() { + var newGroup = createNewGroup() + setCurrentGroup(newGroup.uid) + } + + override suspend fun moveKeyMapsToNewGroup(vararg keyMapUids: String) { + val newGroup = createNewGroup() + moveKeyMapsToGroup(newGroup.uid, *keyMapUids) + setCurrentGroup(newGroup.uid) + } + + private suspend fun createNewGroup(): GroupEntity { val defaultName = resourceProvider.getString(R.string.default_group_name) var group = GroupEntity( - parentUid = groupUid.value, + parentUid = keyMapListGroupUid.value, name = defaultName, lastOpenedDate = System.currentTimeMillis(), ) group = ensureUniqueName(group) groupRepository.insert(group) - - groupUid.update { group.uid } - parentGroupUids.update { it.plus(group.uid) } + return group } override suspend fun deleteGroup() { - groupUid.value?.also { groupUid -> + keyMapListGroupUid.value?.also { groupUid -> val group = groupRepository.getGroup(groupUid) ?: return - this.groupUid.value = group.parentUid - this.parentGroupUids.update { list -> - list.takeWhile { it != group.uid } - } + setCurrentGroup(group.parentUid) + groupRepository.delete(groupUid) } } @@ -140,14 +179,14 @@ class ListKeyMapsUseCaseImpl( return true } - groupUid.value?.also { groupUid -> + keyMapListGroupUid.value?.also { groupUid -> var entity = groupRepository.getGroup(groupUid) ?: return true entity = entity.copy(name = name.trim()) val siblings = groupRepository.getGroupsByParent(entity.parentUid).first() - if (siblings.any { it.name == entity.name }) { + if (siblings.any { it.uid != groupUid && it.name == entity.name }) { return false } @@ -163,7 +202,7 @@ class ListKeyMapsUseCaseImpl( return RepositoryUtils.saveUniqueName( entity = group, saveBlock = { renamedGroup -> - if (siblings.any { sibling -> sibling.name == renamedGroup.name }) { + if (siblings.any { sibling -> sibling.uid != group.uid && sibling.name == renamedGroup.name }) { throw IllegalStateException("Non unique group name") } }, @@ -176,43 +215,31 @@ class ListKeyMapsUseCaseImpl( override suspend fun openGroup(uid: String?) { if (uid == null) { // If null then open the root group. - groupUid.update { null } - parentGroupUids.update { emptyList() } + setCurrentGroup(null) } 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) - } - } - + setCurrentGroup(group.uid) groupRepository.setLastOpenedDate(group.uid, System.currentTimeMillis()) } } override suspend fun popGroup() { - val currentGroupUid = groupUid.value ?: return + val currentGroupUid = keyMapListGroupUid.value ?: return val currentGroup = groupRepository.getGroup(currentGroupUid) // If stuck in a non existent group, or the parent is null then pop to the root. if (currentGroup?.parentUid == null) { - groupUid.value = null - parentGroupUids.update { emptyList() } + setCurrentGroup(null) } else { // Check if the group exists. val group = groupRepository.getGroup(currentGroup.parentUid) ?: return - groupUid.update { group.uid } - parentGroupUids.update { list -> list.dropLast(1) } + setCurrentGroup(group.uid) } } override suspend fun addGroupConstraint(constraint: Constraint) { - groupUid.value?.also { groupUid -> + keyMapListGroupUid.value?.also { groupUid -> val constraintEntity = ConstraintEntityMapper.toEntity(constraint) var groupEntity = groupRepository.getGroup(groupUid) ?: return @@ -229,7 +256,7 @@ class ListKeyMapsUseCaseImpl( } override suspend fun setGroupConstraintMode(mode: ConstraintMode) { - groupUid.value?.also { groupUid -> + keyMapListGroupUid.value?.also { groupUid -> val group = groupRepository.getGroup(groupUid) ?: return val groupEntity = group.copy(constraintMode = ConstraintModeEntityMapper.toEntity(mode)) @@ -243,7 +270,7 @@ class ListKeyMapsUseCaseImpl( } override suspend fun removeGroupConstraint(constraintUid: String) { - groupUid.value?.also { groupUid -> + keyMapListGroupUid.value?.also { groupUid -> val groupEntity = groupRepository.getGroup(groupUid) ?: return var group = GroupEntityMapper.fromEntity(groupEntity) @@ -266,6 +293,10 @@ class ListKeyMapsUseCaseImpl( keyMapRepository.moveToGroup(groupUid, *keyMapUids) } + override fun moveKeyMapsToSelectedGroup(vararg keyMapUids: String) { + keyMapRepository.moveToGroup(selectionGroupUid.value, *keyMapUids) + } + private fun getKeyMapsByGroup(groupUid: String?): Flow>> = channelFlow { send(State.Loading) @@ -333,8 +364,13 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { suspend fun addGroupConstraint(constraint: Constraint) suspend fun removeGroupConstraint(constraintUid: String) suspend fun setGroupConstraintMode(mode: ConstraintMode) - fun getGroups(): Flow> + fun getGroups(parentUid: String?): Flow> + + val selectionGroupFamily: Flow + suspend fun openSelectionGroup(uid: String?) fun moveKeyMapsToGroup(groupUid: String?, vararg keyMapUids: String) + fun moveKeyMapsToSelectedGroup(vararg keyMapUids: String) + suspend fun moveKeyMapsToNewGroup(vararg keyMapUids: String) fun deleteKeyMap(vararg uid: String) fun enableKeyMap(vararg uid: String) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b77db5e27c..f7e65b51f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1385,6 +1385,7 @@ Group constraints New constraint Delete group constraint + This group Remove From 5c14c2de07746f997d1e784209072a2793de48d2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 19:44:42 -0600 Subject: [PATCH 84/94] fix text field behavior when editing group names --- .../java/io/github/sds100/keymapper/home/KeyMapAppBar.kt | 7 +++---- .../keymapper/mappings/keymaps/KeyMapListViewModel.kt | 5 ++++- .../keymapper/mappings/keymaps/ListKeyMapsUseCase.kt | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) 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 index d02824160c..8f86c65e21 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -178,19 +178,18 @@ fun KeyMapAppBar( var showDeleteGroupDialog by remember { mutableStateOf(false) } LaunchedEffect(state.groupName) { - newName = TextFieldValue(state.groupName) showDeleteGroupDialog = false error = null - } - LaunchedEffect(state.isEditingGroupName) { if (state.isEditingGroupName) { if (state.isNewGroup) { newName = TextFieldValue() } else { val endPosition = newName.text.length - newName = newName.copy(selection = TextRange(endPosition)) + newName = TextFieldValue(state.groupName).copy( + selection = TextRange(endPosition), + ) } } } 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 da5928c631..94cab4ac67 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 @@ -780,6 +780,7 @@ class KeyMapListViewModel( fun onDeleteGroupClick() { coroutineScope.launch { + isNewGroup = false isEditingGroupName.update { false } listKeyMaps.deleteGroup() } @@ -787,6 +788,9 @@ class KeyMapListViewModel( fun onNewGroupClick() { coroutineScope.launch { + // Must come first + isNewGroup = true + when (val selectionState = multiSelectProvider.state.value) { is SelectionState.Selecting -> listKeyMaps.moveKeyMapsToNewGroup(*selectionState.selectedIds.toTypedArray()) @@ -797,7 +801,6 @@ class KeyMapListViewModel( } multiSelectProvider.stopSelecting() - isNewGroup = true isEditingGroupName.update { true } } } 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 d526d2677e..c2606560c1 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 @@ -141,7 +141,7 @@ class ListKeyMapsUseCaseImpl( } override suspend fun newGroup() { - var newGroup = createNewGroup() + val newGroup = createNewGroup() setCurrentGroup(newGroup.uid) } From acddde85c6d623f02f0ec213084337f5d352e1f9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 19:58:00 -0600 Subject: [PATCH 85/94] feat: show number of inherited constraints in groups --- .../keymapper/groups/GroupConstraintRow.kt | 23 +++++- .../sds100/keymapper/home/KeyMapAppBar.kt | 73 +++++++++++-------- .../keymaps/CreateKeyMapShortcutViewModel.kt | 1 + .../mappings/keymaps/KeyMapAppBarState.kt | 1 + .../mappings/keymaps/KeyMapListViewModel.kt | 10 ++- .../mappings/keymaps/ListKeyMapsUseCase.kt | 6 +- app/src/main/res/values/strings.xml | 4 + 7 files changed, 80 insertions(+), 38 deletions(-) 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 index 9b74af54b6..37465271bc 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -29,6 +29,7 @@ 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -47,6 +48,7 @@ fun GroupConstraintRow( modifier: Modifier = Modifier, constraints: List, mode: ConstraintMode, + parentConstraintCount: Int, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, onFixConstraintClick: (Error) -> Unit = {}, @@ -122,6 +124,19 @@ fun GroupConstraintRow( } } } + + if (parentConstraintCount > 0) { + Text( + modifier = Modifier + .padding(horizontal = 8.dp), + text = pluralStringResource( + R.plurals.home_groups_inherited_constraints, + parentConstraintCount, + parentConstraintCount, + ), + style = MaterialTheme.typography.labelMedium, + ) + } } } @@ -272,7 +287,11 @@ private fun ConstraintErrorButton( private fun PreviewEmpty() { KeyMapperTheme { Surface { - GroupConstraintRow(constraints = emptyList(), mode = ConstraintMode.AND) + GroupConstraintRow( + constraints = emptyList(), + mode = ConstraintMode.AND, + parentConstraintCount = 0, + ) } } } @@ -291,6 +310,7 @@ private fun PreviewOneItem() { ), ), mode = ConstraintMode.OR, + parentConstraintCount = 1, ) } } @@ -327,6 +347,7 @@ private fun PreviewMultipleItems() { ), ), mode = ConstraintMode.AND, + parentConstraintCount = 3, ) } } 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 index 8f86c65e21..33fb67bf41 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -231,6 +231,7 @@ fun KeyMapAppBar( onGroupClick = onGroupClick, constraints = state.constraints, constraintMode = state.constraintMode, + parentConstraintCount = state.parentConstraintCount, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, onConstraintModeChanged = onConstraintModeChanged, @@ -367,6 +368,7 @@ private fun ChildGroupAppBar( onGroupClick: (String?) -> Unit = {}, constraints: List = emptyList(), constraintMode: ConstraintMode, + parentConstraintCount: Int, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, @@ -413,41 +415,43 @@ private fun ChildGroupAppBar( } } - Column(horizontalAlignment = Alignment.End) { - GroupConstraintRow( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - constraints = constraints, - mode = constraintMode, - onFixConstraintClick = onFixConstraintClick, - onNewConstraintClick = onNewConstraintClick, - onRemoveConstraintClick = onRemoveConstraintClick, - enabled = !isEditingGroupName, - ) + GroupConstraintRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + constraints = constraints, + mode = constraintMode, + parentConstraintCount = parentConstraintCount, + onFixConstraintClick = onFixConstraintClick, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + enabled = !isEditingGroupName, + ) - Spacer(Modifier.height(8.dp)) + 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) - }, - ) + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier.align(Alignment.End), + visible = constraints.size > 1, + ) { + Row { + RadioButtonText( + text = stringResource(R.string.constraint_mode_and), + isSelected = constraintMode == ConstraintMode.AND, + isEnabled = !isEditingGroupName, + onSelected = { + onConstraintModeChanged(ConstraintMode.AND) + }, + ) - RadioButtonText( - text = stringResource(R.string.constraint_mode_or), - isSelected = constraintMode == ConstraintMode.OR, - isEnabled = !isEditingGroupName, - onSelected = { - onConstraintModeChanged(ConstraintMode.OR) - }, - ) - } + RadioButtonText( + text = stringResource(R.string.constraint_mode_or), + isSelected = constraintMode == ConstraintMode.OR, + isEnabled = !isEditingGroupName, + onSelected = { + onConstraintModeChanged(ConstraintMode.OR) + }, + ) } } } @@ -904,6 +908,7 @@ private fun KeyMapsChildGroupPreview() { groupName = "Very very very very very long name", subGroups = groupSampleList(), constraints = constraintsSampleList(), + parentConstraintCount = 1, constraintMode = ConstraintMode.AND, breadcrumbs = groupSampleList(), isEditingGroupName = false, @@ -923,6 +928,7 @@ private fun KeyMapsChildGroupDarkPreview() { subGroups = groupSampleList(), constraints = emptyList(), constraintMode = ConstraintMode.AND, + parentConstraintCount = 0, breadcrumbs = emptyList(), isEditingGroupName = false, isNewGroup = false, @@ -952,6 +958,7 @@ private fun KeyMapsChildGroupEditingPreview() { parentGroups = emptyList(), constraints = emptyList(), constraintMode = ConstraintMode.AND, + parentConstraintCount = 1, ) } } @@ -964,6 +971,7 @@ private fun KeyMapsChildGroupEditingDarkPreview() { groupName = "Untitled group 23", subGroups = groupSampleList(), constraints = constraintsSampleList(), + parentConstraintCount = 3, constraintMode = ConstraintMode.AND, breadcrumbs = emptyList(), isEditingGroupName = true, @@ -1002,6 +1010,7 @@ private fun KeyMapsChildGroupErrorPreview() { parentGroups = emptyList(), constraints = emptyList(), constraintMode = ConstraintMode.AND, + parentConstraintCount = 0, ) } } 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 c1086bed67..102e6582f0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/CreateKeyMapShortcutViewModel.kt @@ -145,6 +145,7 @@ class CreateKeyMapShortcutViewModel( breadcrumbs = parentGroupListItems, isEditingGroupName = false, isNewGroup = false, + parentConstraintCount = keyMapGroup.parents.sumOf { it.constraintState.constraints.size }, ) } 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 index eab57c3e23..12b3030e65 100644 --- 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 @@ -17,6 +17,7 @@ sealed class KeyMapAppBarState { val groupName: String, val constraints: List, val constraintMode: ConstraintMode, + val parentConstraintCount: Int, val subGroups: List, val breadcrumbs: List, val isEditingGroupName: Boolean, 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 94cab4ac67..318b40f42b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt @@ -249,8 +249,9 @@ class KeyMapListViewModel( GroupFamily(null, emptyList(), emptyList()), ) val selectionBreadcrumbs: Flow> = - selectionGroupFamilyStateFlow.map { list -> - list.parents.map { GroupListItemModel(uid = it.uid, name = it.name, icon = null) } + selectionGroupFamilyStateFlow.map { family -> + family.parents.plus(family.group).filterNotNull() + .map { GroupListItemModel(uid = it.uid, name = it.name, icon = null) } } val selectionGroupListItems: Flow> = @@ -377,7 +378,7 @@ class KeyMapListViewModel( buildGroupListItem(group) } - val parentGroupListItems = keyMapGroup.parents.map { group -> + val breadcrumbs = keyMapGroup.parents.plus(keyMapGroup.group).filterNotNull().map { group -> GroupListItemModel( uid = group.uid, name = group.name, @@ -399,8 +400,9 @@ class KeyMapListViewModel( constraintErrorSnapshot, ), constraintMode = keyMapGroup.group.constraintState.mode, + parentConstraintCount = keyMapGroup.parents.sumOf { it.constraintState.constraints.size }, subGroups = subGroupListItems, - breadcrumbs = parentGroupListItems, + breadcrumbs = breadcrumbs, isEditingGroupName = isEditingGroupName, isNewGroup = isNewGroup, ) 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 c2606560c1..d1b233cd92 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt @@ -118,7 +118,11 @@ class ListKeyMapsUseCaseImpl( val list = LinkedList() var count = 0 - var currentGroup: String? = groupUid + if (groupUid == null) { + return emptyList() + } + + var currentGroup: String? = groupRepository.getGroup(groupUid)?.parentUid while (count < 1000) { if (currentGroup == null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7e65b51f9..9d6ba52057 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1461,4 +1461,8 @@ Are you sure you want to delete this group? All the key maps in this group and its subgroups will also be deleted! Yes, delete Cancel + + +%d inherited constraint + +%d inherited constraints + From 9a1983c9712b10979242f8a50b40ef40f7e6187c Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 20:29:27 -0600 Subject: [PATCH 86/94] fix: truncate various chips and breadcrumbs --- .../keymapper/groups/DeleteGroupDialog.kt | 5 +- .../keymapper/groups/GroupBreadcrumbRow.kt | 62 +++++-- .../keymapper/groups/GroupConstraintRow.kt | 164 +++++++++-------- .../sds100/keymapper/groups/GroupRow.kt | 168 ++++++++++-------- .../sds100/keymapper/home/KeyMapAppBar.kt | 25 ++- 5 files changed, 240 insertions(+), 184 deletions(-) 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 index 91468d5e26..29155760f1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/DeleteGroupDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import io.github.sds100.keymapper.R @Composable @@ -23,8 +24,10 @@ fun DeleteGroupDialog( Text( stringResource( R.string.home_key_maps_delete_group_dialog_title, - groupName.take(50), + groupName, ), + maxLines = 3, + overflow = TextOverflow.Ellipsis, ) }, text = { 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 index da9ec9382f..70ff333357 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupBreadcrumbRow.kt @@ -1,7 +1,12 @@ package io.github.sds100.keymapper.groups +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material3.Icon @@ -12,9 +17,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R @@ -25,28 +33,47 @@ fun GroupBreadcrumbRow( 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, - ) + val scrollState = rememberScrollState() + + LaunchedEffect(groups) { + scrollState.animateScrollTo(scrollState.maxValue) + } - for ((index, group) in groups.withIndex()) { - Icon(imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, null, tint = color) + BoxWithConstraints(modifier = modifier) { + val maxCrumbWidth = constraints.maxWidth / 3 + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + ) { + val color = LocalContentColor.current.copy(alpha = 0.7f) Breadcrumb( - text = group.name, - onClick = { onGroupClick(group.uid) }, - color = if (index == groups.lastIndex) { - LocalContentColor.current - } else { - color - }, + text = stringResource(R.string.home_groups_breadcrumb_home), + onClick = { onGroupClick(null) }, + color = color, enabled = enabled, ) + + for ((index, group) in groups.withIndex()) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + null, + tint = color, + ) + + Breadcrumb( + modifier = Modifier.widthIn(max = LocalDensity.current.run { maxCrumbWidth.toDp() }), + text = group.name, + onClick = { onGroupClick(group.uid) }, + color = if (index == groups.lastIndex) { + LocalContentColor.current + } else { + color + }, + enabled = enabled, + ) + } } } } @@ -75,6 +102,7 @@ private fun Breadcrumb( style = MaterialTheme.typography.labelMedium, color = color, maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt index 37465271bc..c98f02f174 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.groups import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -29,8 +31,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter @@ -54,88 +58,96 @@ fun GroupConstraintRow( onFixConstraintClick: (Error) -> Unit = {}, enabled: Boolean = true, ) { - FlowRow( - modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - NewConstraintButton( - onClick = onNewConstraintClick, - showText = constraints.isEmpty(), - enabled = enabled, - ) + BoxWithConstraints(modifier = modifier) { + val maxChipWidth = LocalDensity.current.run { + (this@BoxWithConstraints.constraints.maxWidth / 2).toDp() + } + + FlowRow( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + NewConstraintButton( + onClick = onNewConstraintClick, + showText = constraints.isEmpty(), + enabled = enabled, + ) - for ((index, constraint) in constraints.withIndex()) { - 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, - ) - } - }, + for ((index, constraint) in constraints.withIndex()) { + when (constraint) { + is ComposeChipModel.Normal -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + ConstraintButton( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, + icon = { + if (constraint.icon is ComposeIconInfo.Vector) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = constraint.icon.imageVector, + contentDescription = null, + ) + } else if (constraint.icon is ComposeIconInfo.Drawable) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter(constraint.icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + ) + } + + is ComposeChipModel.Error -> + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer) { + ConstraintErrorButton( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + onClick = { onFixConstraintClick(constraint.error) }, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, + ) + } + } + + if (index < constraints.lastIndex) { + when (mode) { + ConstraintMode.AND -> Text( + text = stringResource(R.string.constraint_mode_and), + style = MaterialTheme.typography.labelMedium, ) - } - 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, + ConstraintMode.OR -> Text( + text = stringResource(R.string.constraint_mode_or), + style = MaterialTheme.typography.labelMedium, ) } - } - - if (index < constraints.lastIndex) { - when (mode) { - ConstraintMode.AND -> Text( - text = stringResource(R.string.constraint_mode_and), - style = MaterialTheme.typography.labelMedium, - ) - - ConstraintMode.OR -> Text( - text = stringResource(R.string.constraint_mode_or), - style = MaterialTheme.typography.labelMedium, - ) } } - } - if (parentConstraintCount > 0) { - Text( - modifier = Modifier - .padding(horizontal = 8.dp), - text = pluralStringResource( - R.plurals.home_groups_inherited_constraints, - parentConstraintCount, - parentConstraintCount, - ), - style = MaterialTheme.typography.labelMedium, - ) + if (parentConstraintCount > 0) { + Text( + modifier = Modifier + .padding(horizontal = 8.dp), + text = pluralStringResource( + R.plurals.home_groups_inherited_constraints, + parentConstraintCount, + parentConstraintCount, + ), + style = MaterialTheme.typography.labelMedium, + ) + } } } } @@ -205,9 +217,11 @@ private fun ConstraintButton( icon() Text( + modifier = Modifier.weight(1f, fill = false), text = text, maxLines = 1, style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.width(4.dp)) @@ -260,9 +274,11 @@ private fun ConstraintErrorButton( ) Text( + modifier = Modifier.weight(1f, fill = false), text = text, maxLines = 1, style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.width(4.dp)) 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 index 487576f491..2e6c35c0b5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -4,15 +4,18 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRowOverflow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -33,7 +36,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter @@ -55,92 +60,98 @@ fun GroupRow( ) { var viewAllState by rememberSaveable { mutableStateOf(false) } - @OptIn(ExperimentalLayoutApi::class) - FlowRow( - modifier - .verticalScroll(rememberScrollState()) - .animateContentSize(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - maxLines = if (viewAllState) { - Int.MAX_VALUE - } else { - 2 - }, - overflow = FlowRowOverflow.expandOrCollapseIndicator( - expandIndicator = { - // Some padding is required on the end to stop it overflowing the screen. - TextGroupButton( - modifier = Modifier.padding(end = 16.dp), - onClick = { viewAllState = true }, - text = stringResource(R.string.home_new_view_all_groups_button), - enabled = enabled, - ) + BoxWithConstraints(modifier = modifier) { + val maxChipWidth = constraints.maxWidth / 2 + + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + maxLines = if (viewAllState) { + Int.MAX_VALUE + } else { + 2 }, - collapseIndicator = { + overflow = FlowRowOverflow.expandOrCollapseIndicator( + expandIndicator = { + // Some padding is required on the end to stop it overflowing the screen. + TextGroupButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { viewAllState = true }, + text = stringResource(R.string.home_new_view_all_groups_button), + enabled = enabled, + ) + }, + collapseIndicator = { + TextGroupButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { viewAllState = false }, + text = stringResource(R.string.home_new_hide_groups_button), + enabled = enabled, + ) + }, + minRowsToShowCollapse = 3, + ), + ) { + NewGroupButton( + onClick = onNewGroupClick, + text = if (isSubgroups) { + stringResource(R.string.home_new_subgroup_button) + } else { + stringResource(R.string.home_new_group_button) + }, + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + enabled = enabled, + ) + + if (showThisGroupButton) { TextGroupButton( - modifier = Modifier.padding(end = 16.dp), - onClick = { viewAllState = false }, - text = stringResource(R.string.home_new_hide_groups_button), + onClick = onThisGroupClick, + text = stringResource(R.string.home_this_group_button), enabled = enabled, ) - }, - minRowsToShowCollapse = 3, - ), - ) { - NewGroupButton( - onClick = onNewGroupClick, - text = if (isSubgroups) { - stringResource(R.string.home_new_subgroup_button) - } else { - stringResource(R.string.home_new_group_button) - }, - icon = { - Icon(imageVector = Icons.Rounded.Add, null) - }, - showText = groups.isEmpty(), - enabled = enabled, - ) + } - if (showThisGroupButton) { - TextGroupButton( - onClick = onThisGroupClick, - text = stringResource(R.string.home_this_group_button), - enabled = enabled, - ) - } + for (group in groups) { + GroupButton( + modifier = Modifier.widthIn(max = LocalDensity.current.run { maxChipWidth.toDp() }), + onClick = { onGroupClick(group.uid) }, + text = group.name, + enabled = enabled, + icon = { + when (group.icon) { + is ComposeIconInfo.Drawable -> { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter(group.icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + } - 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, + ) + } - is ComposeIconInfo.Vector -> { - Icon( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - imageVector = group.icon.imageVector, - contentDescription = null, - ) + null -> {} } - - null -> {} - } - }, - ) + }, + ) + } } } } @@ -258,6 +269,7 @@ private fun GroupButton( maxLines = 1, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, ) } } 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 index 33fb67bf41..d952ec3ae4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/KeyMapAppBar.kt @@ -13,7 +13,6 @@ 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 @@ -29,7 +28,6 @@ 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 @@ -174,7 +172,14 @@ fun KeyMapAppBar( 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 newName by remember { + mutableStateOf( + TextFieldValue( + state.groupName, + selection = TextRange(state.groupName.length), + ), + ) + } var showDeleteGroupDialog by remember { mutableStateOf(false) } LaunchedEffect(state.groupName) { @@ -185,11 +190,10 @@ fun KeyMapAppBar( if (state.isNewGroup) { newName = TextFieldValue() } else { - val endPosition = newName.text.length + val endPosition = state.groupName.length - newName = TextFieldValue(state.groupName).copy( - selection = TextRange(endPosition), - ) + newName = + TextFieldValue(state.groupName, selection = TextRange(endPosition)) } } } @@ -459,15 +463,8 @@ private fun ChildGroupAppBar( Surface { Column { - val scrollState = rememberScrollState() - - LaunchedEffect(parentGroups) { - scrollState.animateScrollTo(scrollState.maxValue) - } - GroupBreadcrumbRow( modifier = Modifier - .horizontalScroll(scrollState) .fillMaxWidth() .padding(8.dp), groups = parentGroups, From 356ab58480d41be83aee1ef632411a3f24c43d2c Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Mar 2025 20:29:38 -0600 Subject: [PATCH 87/94] chore: bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 44da105566..36a6df84b5 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=3.0.0-beta.3 -VERSION_CODE=88 +VERSION_CODE=89 VERSION_NUM=0 \ No newline at end of file From 487d09017683974e7c71f32cffdbdc60e389f1bb Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 09:18:40 -0600 Subject: [PATCH 88/94] chore: bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 36a6df84b5..ce8caecb21 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ VERSION_NAME=3.0.0-beta.3 -VERSION_CODE=89 +VERSION_CODE=90 VERSION_NUM=0 \ No newline at end of file From 415bdc055bce566238de6c6751fa88f9e696e13a Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 09:40:35 -0600 Subject: [PATCH 89/94] #1607 rename parallel triggers to "combination" --- CHANGELOG.md | 1 + app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 211f5fe6fa..4b9e58114b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _See the changes from previous 3.0 Beta releases as well._ - Turn off flashlight when using decrease brightness action. - Animate floating buttons in and out. +- #1607 rename parallel triggers to "combination" ## Bug fixes diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d6ba52057..4307a56c20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,7 +153,7 @@ - At the same time + Combination In sequence AND OR @@ -417,7 +417,7 @@ Your device doesn\'t seem to have an accessibility services settings page. Tap \"guide\" to read the online guide that explains how to fix this. The keys need to be listed from top to bottom in the order that they will be held down. - A \"sequence\" trigger has a timeout unlike parallel triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. + A \"sequence\" trigger has a timeout unlike combination triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Change location or turn off automatic back up? Screen on/off constraints will only work if you have turned on the \"detect trigger when screen is off\" key map option. This option will only show for some keys (e.g volume buttons) and if you are rooted. See a list of supported keys on the Help page. From e18aa39c0148802dcfce9a9a39ad9972f5545cf8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 09:45:50 -0600 Subject: [PATCH 90/94] add back up manager migration from schema version 17 to 18 --- CHANGELOG.md | 2 +- .../java/io/github/sds100/keymapper/backup/BackupManager.kt | 3 +++ .../sds100/keymapper/data/migration/AutoMigration17To18.kt | 5 ----- 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9e58114b..299d2ec797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ _See the changes from previous 3.0 Beta releases as well._ -#### TO BE RELEASED +#### 1 April 2025 ## Added - #320 🗂️ Key map groups! You can now sort key maps into groups and share constraints across all the key maps in the group. 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 d1fb61675f..bc763d0b8e 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 @@ -256,6 +256,9 @@ class BackupManagerImpl( // Do nothing just added nullable column for when a group was last opened JsonMigration(16, 17) { json -> json }, + + // Do nothing. It just removed the group name index. + JsonMigration(17, 18) { json -> json }, ) if (keyMapListJsonArray != null) { diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt deleted file mode 100644 index 1fedac8e2c..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration17To18.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.sds100.keymapper.data.migration - -import androidx.room.migration.AutoMigrationSpec - -class AutoMigration17To18 : AutoMigrationSpec From 1641ea4a86ade0c3bcf89e3e71a165a3a902d2fe Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 10:22:00 -0600 Subject: [PATCH 91/94] fix: back up empty groups and floating layouts in auto back up --- .../sds100/keymapper/backup/BackupManager.kt | 230 +++++++++--------- .../data/entities/FloatingButtonEntity.kt | 2 +- .../data/repositories/RoomKeyMapRepository.kt | 26 -- .../mappings/keymaps/KeyMapRepository.kt | 1 - .../sds100/keymapper/BackupManagerTest.kt | 80 ++++-- 5 files changed, 186 insertions(+), 153 deletions(-) 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 bc763d0b8e..4d0a584670 100644 --- a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt +++ b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt @@ -26,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.FloatingLayoutEntityWithButtons import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.TriggerEntity @@ -59,13 +60,11 @@ import io.github.sds100.keymapper.util.then import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -128,39 +127,25 @@ class BackupManagerImpl( .get(Keys.automaticBackupLocation).map { it != null } init { - val doAutomaticBackup = MutableSharedFlow() - coroutineScope.launch { - doAutomaticBackup.collectLatest { backupData -> - if (!backupAutomatically.first()) return@collectLatest - - val backupLocation = preferenceRepository.get(Keys.automaticBackupLocation).first() - ?: return@collectLatest + combine( + backupAutomatically, + preferenceRepository.get(Keys.automaticBackupLocation), + keyMapRepository.keyMapList.filterIsInstance>>(), + groupRepository.getAllGroups(), + floatingLayoutRepository.layouts.filterIsInstance>>(), + ) { backupAutomatically, location, keyMaps, groups, floatingLayouts -> + if (!backupAutomatically) { + return@combine + } - val outputFile = fileAdapter.getFileFromUri(backupLocation) - val result = backupAsync(outputFile, backupData.keyMapList) + location ?: return@combine + val outputFile = fileAdapter.getFileFromUri(location) + val result = backupAsync(outputFile, keyMaps.data, groups, floatingLayouts.data) onAutomaticBackupResult.emit(result) - } + }.collect() } - - coroutineScope.launch { - keyMapRepository.requestBackup.collectLatest { keyMapList -> - val backupData = AutomaticBackup(keyMapList = keyMapList) - - doAutomaticBackup.emit(backupData) - } - } - - // automatically back up when the location changes - preferenceRepository.get(Keys.automaticBackupLocation).drop(1).onEach { - val keyMaps = - keyMapRepository.keyMapList.first { it is State.Data } as State.Data - - val data = AutomaticBackup(keyMapList = keyMaps.data) - - doAutomaticBackup.emit(data) - }.launchIn(coroutineScope) } override suspend fun backupKeyMaps(output: IFile, keyMapIds: List): Result { @@ -186,7 +171,11 @@ class BackupManagerImpl( val groups = groupRepository.getAllGroups().first() - backupAsync(output, keyMaps.data, groups) + val layouts = floatingLayoutRepository.layouts + .filterIsInstance>>() + .first() + + backupAsync(output, keyMaps.data, groups, layouts.data) Success(Unit) } @@ -624,8 +613,9 @@ class BackupManagerImpl( private suspend fun backupAsync( output: IFile, - keyMapList: List? = null, + keyMapList: List = emptyList(), extraGroups: List = emptyList(), + extraLayouts: List = emptyList(), ): Result { return withContext(dispatchers.io()) { val backupUid = uuidGenerator.random() @@ -637,80 +627,7 @@ class BackupManagerImpl( // delete the contents of the file output.clear() - val floatingLayouts: MutableList = mutableListOf() - val floatingButtons: MutableList = mutableListOf() - val groupMap: MutableMap = mutableMapOf() - - if (keyMapList != null) { - val floatingButtonTriggerKeys = keyMapList - .flatMap { it.trigger.keys } - .filterIsInstance() - .map { it.buttonUid } - .distinct() - - for (buttonUid in floatingButtonTriggerKeys) { - val buttonWithLayout = floatingButtonRepository.get(buttonUid) ?: continue - - if (floatingLayouts.none { it.uid == buttonWithLayout.layout.uid }) { - floatingLayouts.add(buttonWithLayout.layout) - } - - floatingButtons.add(buttonWithLayout.button) - } - - 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( - AppDatabase.DATABASE_VERSION, - Constants.VERSION_CODE, - keyMapList, - defaultLongPressDelay = - preferenceRepository - .get(Keys.defaultLongPressDelay) - .first() - .takeIf { it != PreferenceDefaults.LONG_PRESS_DELAY }, - defaultDoublePressDelay = - preferenceRepository - .get(Keys.defaultDoublePressDelay) - .first() - .takeIf { it != PreferenceDefaults.DOUBLE_PRESS_DELAY }, - defaultRepeatDelay = - preferenceRepository - .get(Keys.defaultRepeatDelay) - .first() - .takeIf { it != PreferenceDefaults.REPEAT_DELAY }, - defaultRepeatRate = - preferenceRepository - .get(Keys.defaultRepeatRate) - .first() - .takeIf { it != PreferenceDefaults.REPEAT_RATE }, - defaultSequenceTriggerTimeout = - preferenceRepository - .get(Keys.defaultSequenceTriggerTimeout) - .first() - .takeIf { it != PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT }, - defaultVibrationDuration = - preferenceRepository - .get(Keys.defaultVibrateDuration) - .first() - .takeIf { it != PreferenceDefaults.VIBRATION_DURATION }, - floatingLayouts = floatingLayouts.takeIf { it.isNotEmpty() }, - floatingButtons = floatingButtons.takeIf { it.isNotEmpty() }, - groups = groupMap.values.toList(), - ) + val backupContent = createBackupContent(keyMapList, extraGroups, extraLayouts) val json = gson.toJson(backupContent) @@ -758,9 +675,100 @@ class BackupManagerImpl( } } - private data class AutomaticBackup( - val keyMapList: List?, - ) + suspend fun createBackupContent( + keyMapList: List, + extraGroups: List, + extraLayouts: List, + ): BackupContent { + val floatingLayoutsMap: MutableMap = mutableMapOf() + val floatingButtonsMap: MutableMap = mutableMapOf() + val groupMap: MutableMap = mutableMapOf() + + val floatingButtonTriggerKeys = keyMapList + .flatMap { it.trigger.keys } + .filterIsInstance() + .map { it.buttonUid } + .distinct() + + for (buttonUid in floatingButtonTriggerKeys) { + val buttonWithLayout = floatingButtonRepository.get(buttonUid) ?: continue + val layoutUid = buttonWithLayout.layout.uid + + if (!floatingLayoutsMap.containsKey(layoutUid)) { + floatingLayoutsMap[layoutUid] = buttonWithLayout.layout + } + + if (!floatingButtonsMap.containsKey(buttonUid)) { + floatingButtonsMap[buttonUid] = buttonWithLayout.button + } + } + + for (keyMap in keyMapList) { + val groupUid = keyMap.groupUid ?: continue + if (!groupMap.containsKey(groupUid)) { + val groupEntity = groupRepository.getGroup(groupUid) ?: continue + groupMap[groupUid] = groupEntity + } + } + + for (group in extraGroups) { + if (!groupMap.containsKey(group.uid)) { + groupMap[group.uid] = group + } + } + + for (layoutWithButtons in extraLayouts) { + if (!floatingLayoutsMap.containsKey(layoutWithButtons.layout.uid)) { + floatingLayoutsMap[layoutWithButtons.layout.uid] = layoutWithButtons.layout + } + + for (button in layoutWithButtons.buttons) { + if (!floatingButtonsMap.containsKey(button.uid)) { + floatingButtonsMap[button.uid] = button + } + } + } + + val backupContent = BackupContent( + AppDatabase.DATABASE_VERSION, + Constants.VERSION_CODE, + keyMapList, + defaultLongPressDelay = + preferenceRepository + .get(Keys.defaultLongPressDelay) + .first() + .takeIf { it != PreferenceDefaults.LONG_PRESS_DELAY }, + defaultDoublePressDelay = + preferenceRepository + .get(Keys.defaultDoublePressDelay) + .first() + .takeIf { it != PreferenceDefaults.DOUBLE_PRESS_DELAY }, + defaultRepeatDelay = + preferenceRepository + .get(Keys.defaultRepeatDelay) + .first() + .takeIf { it != PreferenceDefaults.REPEAT_DELAY }, + defaultRepeatRate = + preferenceRepository + .get(Keys.defaultRepeatRate) + .first() + .takeIf { it != PreferenceDefaults.REPEAT_RATE }, + defaultSequenceTriggerTimeout = + preferenceRepository + .get(Keys.defaultSequenceTriggerTimeout) + .first() + .takeIf { it != PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT }, + defaultVibrationDuration = + preferenceRepository + .get(Keys.defaultVibrateDuration) + .first() + .takeIf { it != PreferenceDefaults.VIBRATION_DURATION }, + floatingLayouts = floatingLayoutsMap.values.toList().takeIf { it.isNotEmpty() }, + floatingButtons = floatingButtonsMap.values.toList().takeIf { it.isNotEmpty() }, + groups = groupMap.values.toList().takeIf { it.isNotEmpty() }, + ) + return backupContent + } } interface BackupManager { diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt index 233887712d..1c1589053e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt @@ -80,7 +80,7 @@ data class FloatingButtonEntity( @ColumnInfo(name = KEY_BACKGROUND_OPACITY) @SerializedName(NAME_BACKGROUND_OPACITY) - val backgroundOpacity: Float? = 1f, + val backgroundOpacity: Float?, ) : Parcelable { companion object { 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 ebfad89665..2a0f1737a2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -12,7 +12,6 @@ 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 import kotlinx.coroutines.flow.flowOn @@ -38,13 +37,9 @@ class RoomKeyMapRepository( .flowOn(dispatchers.io()) .stateIn(coroutineScope, SharingStarted.Eagerly, State.Loading) - override val requestBackup = MutableSharedFlow>() - init { coroutineScope.launch { migrateFingerprintMaps() - - requestBackup() } } @@ -61,8 +56,6 @@ class RoomKeyMapRepository( for (it in keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.insert(*it) } - - requestBackup() } } @@ -77,8 +70,6 @@ class RoomKeyMapRepository( for (it in keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.update(*it) } - - requestBackup() } } @@ -89,8 +80,6 @@ class RoomKeyMapRepository( for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.deleteById(*it) } - - requestBackup() } } @@ -106,8 +95,6 @@ class RoomKeyMapRepository( keyMapDao.insert(*keymaps.toTypedArray()) } - - requestBackup() } } @@ -116,8 +103,6 @@ class RoomKeyMapRepository( for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.enableKeyMapByUid(*it) } - - requestBackup() } } @@ -126,8 +111,6 @@ class RoomKeyMapRepository( for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.disableKeyMapByUid(*it) } - - requestBackup() } } @@ -136,8 +119,6 @@ class RoomKeyMapRepository( for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { keyMapDao.setKeyMapGroup(groupUid, *it) } - - requestBackup() } } @@ -153,11 +134,4 @@ class RoomKeyMapRepository( fingerprintMapDao.update(migratedFingerprintMapEntity) } } - - private fun requestBackup() { - coroutineScope.launch { - val keyMapList = keyMapList.first { it is State.Data } as State.Data - requestBackup.emit(keyMapList.data) - } - } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt index dfbdaece56..0d42b9c77c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapRepository.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow */ interface KeyMapRepository { val keyMapList: Flow>> - val requestBackup: Flow> fun getAll(): Flow> fun getByGroup(groupUid: String?): Flow> 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 48a02abec9..2bfa190c60 100644 --- a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt @@ -10,8 +10,13 @@ import io.github.sds100.keymapper.backup.RestoreType import io.github.sds100.keymapper.data.db.AppDatabase import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.FloatingButtonEntity +import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity +import io.github.sds100.keymapper.data.entities.FloatingLayoutEntityWithButtons +import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.repositories.FakePreferenceRepository +import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository @@ -24,7 +29,6 @@ import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.UuidGenerator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -33,6 +37,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers import org.hamcrest.Matchers.`is` import org.hamcrest.core.IsInstanceOf import org.junit.After @@ -88,9 +93,7 @@ class BackupManagerTest { fakePreferenceRepository = FakePreferenceRepository() - mockKeyMapRepository = mock { - on { requestBackup }.then { MutableSharedFlow>() } - } + mockKeyMapRepository = mock() fakeFileAdapter = FakeFileAdapter(temporaryFolder) @@ -110,7 +113,9 @@ class BackupManagerTest { soundsManager = mockSoundsManager, uuidGenerator = mockUuidGenerator, floatingButtonRepository = mock {}, - floatingLayoutRepository = mock {}, + floatingLayoutRepository = mock { + on { layouts } doReturn MutableStateFlow(State.Data(emptyList())) + }, groupRepository = mock { on { getAllGroups() } doReturn MutableStateFlow(emptyList()) }, @@ -125,13 +130,66 @@ class BackupManagerTest { Dispatchers.resetMain() } + @Test + fun `when backing up everything include layouts that are not in the list of key maps`() = runTest(testDispatcher) { + val layoutWithButtons = FloatingLayoutEntityWithButtons( + layout = FloatingLayoutEntity( + uid = "layout_uid", + name = "layout_name", + ), + buttons = listOf( + FloatingButtonEntity( + uid = "button_uid", + layoutUid = "layout_uid", + text = "Button", + buttonSize = 10, + x = 0, + y = 0, + orientation = "orientation", + displayWidth = 100, + displayHeight = 100, + borderOpacity = null, + backgroundOpacity = null, + ), + ), + ) + + val content = backupManager.createBackupContent( + keyMapList = emptyList(), + extraGroups = emptyList(), + extraLayouts = listOf(layoutWithButtons), + ) + + assertThat(content.floatingLayouts, Matchers.contains(layoutWithButtons.layout)) + assertThat( + content.floatingButtons, + Matchers.contains(*layoutWithButtons.buttons.toTypedArray()), + ) + } + + @Test + fun `when backing up everything include groups that are not in the list of key maps`() = runTest(testDispatcher) { + val group = GroupEntity( + uid = "group_uid", + name = "group_name", + parentUid = null, + lastOpenedDate = 0L, + ) + + val content = backupManager.createBackupContent( + keyMapList = emptyList(), + extraGroups = listOf(group), + extraLayouts = emptyList(), + ) + + assertThat(content.groups, Matchers.contains(group)) + } + /** * #745 */ @Test fun `Don't allow back ups from a newer version of key mapper`() = runTest(testDispatcher) { - advanceUntilIdle() - // GIVEN val dataJsonFile = "restore-app-version-too-big.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") @@ -150,11 +208,8 @@ class BackupManagerTest { * #745 */ @Test - fun `Allow back ups from a back up without a key mapper version in it`() = runTest(testDispatcher) { + fun `Allow restoring a back up without a key mapper version in it`() = runTest(testDispatcher) { // GIVEN - whenever(mockKeyMapRepository.keyMapList).then { - MutableStateFlow(State.Data(emptyList())) - } val dataJsonFile = "restore-no-app-version.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") @@ -172,9 +227,6 @@ class BackupManagerTest { @Test fun `don't crash if back up does not contain sounds folder`() = runTest(testDispatcher) { // GIVEN - whenever(mockKeyMapRepository.keyMapList).then { - MutableStateFlow(State.Data(emptyList())) - } val dataJsonFile = "restore-no-sounds-folder.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") From 0d47cfd32dcacfab07cbbccd471743eb0cfb9f0e Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 10:24:03 -0600 Subject: [PATCH 92/94] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299d2ec797..198b197f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ _See the changes from previous 3.0 Beta releases as well._ #### 1 April 2025 +This is not an April Fool's joke ;) + ## Added - #320 🗂️ Key map groups! You can now sort key maps into groups and share constraints across all the key maps in the group. - #1586 🎨 Customise floating button border and background opacity. From e0272ba9ed8e04c1a694dc702d1f18f318eb60b4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 12:04:27 -0600 Subject: [PATCH 93/94] Revert "#1607 rename parallel triggers to "combination"" This reverts commit 415bdc055bce566238de6c6751fa88f9e696e13a. --- CHANGELOG.md | 1 - app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 198b197f36..3ed837fe12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ This is not an April Fool's joke ;) - Turn off flashlight when using decrease brightness action. - Animate floating buttons in and out. -- #1607 rename parallel triggers to "combination" ## Bug fixes diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4307a56c20..9d6ba52057 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,7 +153,7 @@ - Combination + At the same time In sequence AND OR @@ -417,7 +417,7 @@ Your device doesn\'t seem to have an accessibility services settings page. Tap \"guide\" to read the online guide that explains how to fix this. The keys need to be listed from top to bottom in the order that they will be held down. - A \"sequence\" trigger has a timeout unlike combination triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. + A \"sequence\" trigger has a timeout unlike parallel triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Change location or turn off automatic back up? Screen on/off constraints will only work if you have turned on the \"detect trigger when screen is off\" key map option. This option will only show for some keys (e.g volume buttons) and if you are rooted. See a list of supported keys on the Help page. From 4055ea2a7caab069985387f3a1b2dac38039323c Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Apr 2025 13:47:39 -0600 Subject: [PATCH 94/94] #1612 move new group and new constraint buttons to the end --- .../keymapper/groups/GroupConstraintRow.kt | 12 ++-- .../sds100/keymapper/groups/GroupRow.kt | 63 ++++++++++++------- 2 files changed, 48 insertions(+), 27 deletions(-) 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 index c98f02f174..6ba5aeb4ef 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupConstraintRow.kt @@ -69,12 +69,6 @@ fun GroupConstraintRow( horizontalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically, ) { - NewConstraintButton( - onClick = onNewConstraintClick, - showText = constraints.isEmpty(), - enabled = enabled, - ) - for ((index, constraint) in constraints.withIndex()) { when (constraint) { is ComposeChipModel.Normal -> @@ -148,6 +142,12 @@ fun GroupConstraintRow( style = MaterialTheme.typography.labelMedium, ) } + + NewConstraintButton( + onClick = onNewConstraintClick, + showText = constraints.isEmpty(), + enabled = enabled, + ) } } } 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 index 2e6c35c0b5..a9f112206a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/groups/GroupRow.kt @@ -77,15 +77,36 @@ fun GroupRow( }, overflow = FlowRowOverflow.expandOrCollapseIndicator( expandIndicator = { - // Some padding is required on the end to stop it overflowing the screen. - TextGroupButton( - modifier = Modifier.padding(end = 16.dp), - onClick = { viewAllState = true }, - text = stringResource(R.string.home_new_view_all_groups_button), - enabled = enabled, - ) + // Show new group button in the expand indicator if the new group button + // in the flow row has overflowed. + Row { + NewGroupButton( + onClick = onNewGroupClick, + text = if (isSubgroups) { + stringResource(R.string.home_new_subgroup_button) + } else { + stringResource(R.string.home_new_group_button) + }, + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + enabled = enabled, + ) + + Spacer(Modifier.width(8.dp)) + + // Some padding is required on the end to stop it overflowing the screen. + TextGroupButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { viewAllState = true }, + text = stringResource(R.string.home_new_view_all_groups_button), + enabled = enabled, + ) + } }, collapseIndicator = { + // Some padding is required on the end to stop it overflowing the screen. TextGroupButton( modifier = Modifier.padding(end = 16.dp), onClick = { viewAllState = false }, @@ -96,20 +117,6 @@ fun GroupRow( minRowsToShowCollapse = 3, ), ) { - NewGroupButton( - onClick = onNewGroupClick, - text = if (isSubgroups) { - stringResource(R.string.home_new_subgroup_button) - } else { - stringResource(R.string.home_new_group_button) - }, - icon = { - Icon(imageVector = Icons.Rounded.Add, null) - }, - showText = groups.isEmpty(), - enabled = enabled, - ) - if (showThisGroupButton) { TextGroupButton( onClick = onThisGroupClick, @@ -152,6 +159,20 @@ fun GroupRow( }, ) } + + NewGroupButton( + onClick = onNewGroupClick, + text = if (isSubgroups) { + stringResource(R.string.home_new_subgroup_button) + } else { + stringResource(R.string.home_new_group_button) + }, + icon = { + Icon(imageVector = Icons.Rounded.Add, null) + }, + showText = groups.isEmpty(), + enabled = enabled, + ) } } }