diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad1fcbbdc..dae9ef2e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## [4.6.0](https://github.com/sds100/KeyMapper/releases/tag/v4.6.0) + +#### TO BE RELEASED + +## Added + +- #711 new action to add to, subtract from or set a variable, and a new constraint for comparing a variable to a value. +- #1493 the brightness increment for the increase/decrease brightness actions can now be configured, from 1% to 50% (default 10%). +- #2075 enable/disable/toggle a group by sending a broadcast intent, configurable from Power user options in Settings. +- #2263 constraints can now be edited in place from the constraints list instead of being deleted and re-added. +- #2254 you can now give a key map a custom name, which is shown in the key map list. +- #2252 undo and redo changes when configuring a key map. +- #2155 the Expert Mode setup wizard now detects Samsung Auto Blocker and guides how to disable it, since it can silently block wireless debugging from staying enabled. +- #2127 shut down and restart device actions. +- #2281 action text in the key map list is more concise and the delay between actions is shown as a timer chip. +- Show constraint icons when collapsed. + +## Changed + +- #2280 removed the confirmation dialogs when deleting actions, constraints and constraint groups because changes can now be undone. +- #2252 redesigned the app bar when configuring a key map. Changes are saved automatically so there is no Done button, and the help button was removed. +- Show side-by-side view when creating a key map on more devices +- #2273 Constraints that the device can't support (e.g. no Bluetooth or hinge sensor) are now shown as unsupported when choosing a constraint. +- Rename "app in foreground" constraint to "app is open". +- #2282 moved the group enable switch into the top app bar to save vertical space. + +## Fixed + +- #2268 use trampoline when launching device assistant and settings apps when the device is locked so the lock screen dismisses. +- #2271 brightness actions match brightness slider on Android 16.1+. +- #1739 support multiple displays better. +- #2276 Bottom key maps are obscured by selection bottom sheet. +- Disable Wireless Debugging after manually starting Expert Mode. +- #2285 show floating button border when configuring even if background is not transparent. +- #2283 only show side key app in the launcher if the assistant trigger is used by a key map + ## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.5.0) #### 21 September 2026 diff --git a/README.md b/README.md index 109931dc00..203a4363fb 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,6 @@

-

- -

- ## Unleash your keys! Make custom macros on your keyboard or gamepad, make on-screen buttons in any app, and unlock new functionality from your volume buttons! diff --git a/api/src/main/AndroidManifest.xml b/api/src/main/AndroidManifest.xml index 6965340ec4..6ded45094f 100644 --- a/api/src/main/AndroidManifest.xml +++ b/api/src/main/AndroidManifest.xml @@ -46,6 +46,16 @@ + + + + + + + + diff --git a/api/src/main/java/io/github/sds100/keymapper/api/Api.kt b/api/src/main/java/io/github/sds100/keymapper/api/Api.kt index fdc69649e8..bfe84d827f 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/Api.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/Api.kt @@ -13,4 +13,9 @@ object Api { const val ACTION_ENABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_ENABLE_KEY_MAP" const val ACTION_DISABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_DISABLE_KEY_MAP" const val ACTION_TOGGLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_TOGGLE_KEY_MAP" + + const val ACTION_ENABLE_GROUP = "io.github.sds100.keymapper.ACTION_ENABLE_GROUP" + const val ACTION_DISABLE_GROUP = "io.github.sds100.keymapper.ACTION_DISABLE_GROUP" + const val ACTION_TOGGLE_GROUP = "io.github.sds100.keymapper.ACTION_TOGGLE_GROUP" + const val EXTRA_GROUP_UID = "io.github.sds100.keymapper.EXTRA_GROUP_UID" } diff --git a/api/src/main/java/io/github/sds100/keymapper/api/EnableGroupBroadcastReceiver.kt b/api/src/main/java/io/github/sds100/keymapper/api/EnableGroupBroadcastReceiver.kt new file mode 100644 index 0000000000..f41559284c --- /dev/null +++ b/api/src/main/java/io/github/sds100/keymapper/api/EnableGroupBroadcastReceiver.kt @@ -0,0 +1,36 @@ +package io.github.sds100.keymapper.api + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import io.github.sds100.keymapper.base.keymaps.EnableGroupUseCase +import javax.inject.Inject + +// DON'T MOVE THIS CLASS TO A DIFFERENT PACKAGE OR RENAME BECAUSE IT BREAKS THE API +@AndroidEntryPoint +class EnableGroupBroadcastReceiver : BroadcastReceiver() { + + @Inject + lateinit var useCase: EnableGroupUseCase + + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + intent?.action ?: return + + if (intent.action != Api.ACTION_ENABLE_GROUP && + intent.action != Api.ACTION_DISABLE_GROUP && + intent.action != Api.ACTION_TOGGLE_GROUP + ) { + return + } + + val groupUid = intent.getStringExtra(Api.EXTRA_GROUP_UID) ?: return + + when (intent.action) { + Api.ACTION_ENABLE_GROUP -> useCase.enable(groupUid) + Api.ACTION_DISABLE_GROUP -> useCase.disable(groupUid) + Api.ACTION_TOGGLE_GROUP -> useCase.toggle(groupUid) + } + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0f7be6ae53..2de9935ad1 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -82,6 +82,8 @@ -keep class android.hardware.usb.IUsbManager { *; } -keep class android.hardware.usb.IUsbManager$Stub { *; } -keep class android.net.* { *; } +-keep class android.os.IPowerManager { *; } +-keep class android.os.IPowerManager$Stub { *; } -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations diff --git a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt index c8afa23700..b3d7b88f6e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt +++ b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt @@ -7,12 +7,9 @@ import io.github.sds100.keymapper.base.purchasing.RevenueCatState import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow class PurchasingManagerImpl : PurchasingManager { - override val onCompleteProductPurchase: MutableSharedFlow = - MutableSharedFlow() override val state: Flow>> = MutableStateFlow(State.Data(PurchasingError.PurchasingNotImplemented)) diff --git a/app/version.properties b/app/version.properties index 7a9b55b422..900f1ef980 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.5.0 -VERSION_CODE=271 +VERSION_NAME=4.6.0 +VERSION_CODE=277 diff --git a/base/src/main/assets/whats-new-app.txt b/base/src/main/assets/whats-new-app.txt index 6733687866..ae1f57f2a5 100644 --- a/base/src/main/assets/whats-new-app.txt +++ b/base/src/main/assets/whats-new-app.txt @@ -1,4 +1,4 @@ -! Group constraints | Organize constraints into groups with their own AND/OR logic +! Constraint groups | Organize constraints into groups with their own AND/OR logic ! Vibrate action | New action with custom duration or haptic effects like Click and Tick + • Cycle enabled keyboards action + • Notification constraint diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt index f17b03077e..18422216c0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt @@ -166,7 +166,7 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { } } - private fun init() { + open fun init() { Log.i(tag, "KeyMapperApp: Init") val intentFilter = IntentFilter().apply { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt index f5c7d7e85e..5da47909de 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt @@ -7,7 +7,9 @@ import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.net.Uri +import android.os.Build import android.os.Bundle +import android.view.Display import android.view.MotionEvent import android.widget.Toast import androidx.activity.SystemBarStyle @@ -38,6 +40,7 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupControllerImpl import io.github.sds100.keymapper.system.devices.AndroidDevicesAdapter +import io.github.sds100.keymapper.system.display.AndroidDisplayAdapter import io.github.sds100.keymapper.system.files.FileUtils import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent import io.github.sds100.keymapper.system.network.AndroidNetworkAdapter @@ -106,6 +109,9 @@ abstract class BaseMainActivity : AppCompatActivity() { @Inject lateinit var configKeyMapState: ConfigKeyMapStateImpl + @Inject + lateinit var displayAdapter: AndroidDisplayAdapter + private lateinit var requestPermissionDelegate: RequestPermissionDelegate private val currentNightMode: Int @@ -205,6 +211,11 @@ abstract class BaseMainActivity : AppCompatActivity() { onboardingUseCase.handledMigrateScreenOffKeyMapsNotification() } + override fun onPause() { + configKeyMapState.save() + super.onPause() + } + override fun onSaveInstanceState(outState: Bundle) { configKeyMapState.saveState(outState) @@ -219,6 +230,16 @@ abstract class BaseMainActivity : AppCompatActivity() { super.onDestroy() } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + displayAdapter.activityDisplayId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + display?.displayId + } else { + Display.DEFAULT_DISPLAY + } + } + /** * Process motion events from the activity so that DPAD buttons can be recorded * even when the Key Mapper IME is not being used. DO NOT record the key events because diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 26e7098f8c..9577b8a645 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -32,7 +32,9 @@ import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationScreen +import io.github.sds100.keymapper.base.groups.EnableGroupByIntentScreen import io.github.sds100.keymapper.base.keymaps.EnableByIntentScreen +import io.github.sds100.keymapper.base.keymaps.PauseResumeByIntentScreen import io.github.sds100.keymapper.base.keymaps.TriggerByIntentScreen import io.github.sds100.keymapper.base.logging.LogScreen import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs @@ -106,9 +108,13 @@ fun BaseMainNavHost( ) } - composable { + composable { backStackEntry -> val viewModel: ConfigNotificationConstraintViewModel = hiltViewModel() + backStackEntry.handleRouteArgs { + it.oldJson?.let { json -> viewModel.loadOldData(Json.decodeFromString(json)) } + } + ConfigNotificationConstraintScreen( modifier = Modifier.fillMaxSize(), viewModel = viewModel, @@ -223,6 +229,20 @@ fun BaseMainNavHost( ) } + composable { + PauseResumeByIntentScreen( + modifier = Modifier.fillMaxSize(), + onBackClick = { navController.popBackStack() }, + ) + } + + composable { + EnableGroupByIntentScreen( + modifier = Modifier.fillMaxSize(), + viewModel = hiltViewModel(), + ) + } + composableDestinations() } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt index db1df55647..4ac8d20573 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt @@ -20,6 +20,8 @@ import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.input.InputEventHubImpl import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl +import io.github.sds100.keymapper.base.keymaps.EnableGroupUseCase +import io.github.sds100.keymapper.base.keymaps.EnableGroupUseCaseImpl import io.github.sds100.keymapper.base.keymaps.EnableKeyMapsUseCase import io.github.sds100.keymapper.base.keymaps.EnableKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase @@ -55,6 +57,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.DialogProviderImpl import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl +import io.github.sds100.keymapper.base.variables.VariablesUseCase +import io.github.sds100.keymapper.base.variables.VariablesUseCaseImpl import io.github.sds100.keymapper.common.utils.Clock import io.github.sds100.keymapper.common.utils.ClockImpl import io.github.sds100.keymapper.common.utils.DefaultUuidGenerator @@ -199,6 +203,10 @@ abstract class BaseSingletonHiltModule { @Singleton abstract fun bindEnableKeyMapsUseCase(impl: EnableKeyMapsUseCaseImpl): EnableKeyMapsUseCase + @Binds + @Singleton + abstract fun bindEnableGroupUseCase(impl: EnableGroupUseCaseImpl): EnableGroupUseCase + @Binds @Singleton abstract fun bindSetupAccessibilityServiceDelegate( @@ -208,4 +216,8 @@ abstract class BaseSingletonHiltModule { @Binds @Singleton abstract fun bindClock(impl: ClockImpl): Clock + + @Binds + @Singleton + abstract fun bindVariablesUseCase(impl: VariablesUseCaseImpl): VariablesUseCase } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt index d664c6373d..0126f22c07 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt @@ -31,6 +31,8 @@ import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupUseCase import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupUseCaseImpl import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationUseCase import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationUseCaseImpl +import io.github.sds100.keymapper.base.groups.SelectGroupUseCase +import io.github.sds100.keymapper.base.groups.SelectGroupUseCaseImpl import io.github.sds100.keymapper.base.home.ListKeyMapsUseCase import io.github.sds100.keymapper.base.home.ListKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.home.ShowHomeScreenAlertsUseCase @@ -85,6 +87,10 @@ abstract class BaseViewModelHiltModule { @ViewModelScoped abstract fun bindListKeyMapsUseCase(impl: ListKeyMapsUseCaseImpl): ListKeyMapsUseCase + @Binds + @ViewModelScoped + abstract fun bindSelectGroupUseCase(impl: SelectGroupUseCaseImpl): SelectGroupUseCase + @Binds @ViewModelScoped abstract fun bindBackupRestoreMappingsUseCase( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt b/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt index aa47c3cd58..0ad8e3b8e6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt @@ -7,4 +7,19 @@ object IntentApi { const val TRIGGER_RECEIVER_CLASS = "io.github.sds100.keymapper.api.TriggerKeyMapsBroadcastReceiver" + + const val ACTION_PAUSE_MAPPINGS = "io.github.sds100.keymapper.ACTION_PAUSE_MAPPINGS" + const val ACTION_RESUME_MAPPINGS = "io.github.sds100.keymapper.ACTION_RESUME_MAPPINGS" + const val ACTION_TOGGLE_MAPPINGS = "io.github.sds100.keymapper.ACTION_TOGGLE_MAPPINGS" + + const val PAUSE_RECEIVER_CLASS = + "io.github.sds100.keymapper.api.PauseMappingsBroadcastReceiver" + + const val ACTION_ENABLE_GROUP = "io.github.sds100.keymapper.ACTION_ENABLE_GROUP" + const val ACTION_DISABLE_GROUP = "io.github.sds100.keymapper.ACTION_DISABLE_GROUP" + const val ACTION_TOGGLE_GROUP = "io.github.sds100.keymapper.ACTION_TOGGLE_GROUP" + const val EXTRA_GROUP_UID = "io.github.sds100.keymapper.EXTRA_GROUP_UID" + + const val GROUP_RECEIVER_CLASS = + "io.github.sds100.keymapper.api.EnableGroupBroadcastReceiver" } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionCategory.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionCategory.kt index 6b7cde4839..99c7f14443 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionCategory.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionCategory.kt @@ -14,4 +14,5 @@ enum class ActionCategory { TELEPHONY, NOTIFICATIONS, SPECIAL, + VARIABLES, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index ed244f3d63..62138836d5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.actions import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType +import io.github.sds100.keymapper.base.variables.VariableOperation import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.NodeInteractionType @@ -776,12 +777,12 @@ sealed class ActionData : Comparable { } @Serializable - data object Increase : Brightness() { + data class Increase(val stepPercent: Int? = null) : Brightness() { override val id = ActionId.INCREASE_BRIGHTNESS } @Serializable - data object Decrease : Brightness() { + data class Decrease(val stepPercent: Int? = null) : Brightness() { override val id = ActionId.DECREASE_BRIGHTNESS } } @@ -980,6 +981,16 @@ sealed class ActionData : Comparable { override val id = ActionId.SHOW_POWER_MENU } + @Serializable + data object ShutdownDevice : ActionData() { + override val id = ActionId.SHUTDOWN_DEVICE + } + + @Serializable + data object RebootDevice : ActionData() { + override val id = ActionId.REBOOT_DEVICE + } + @Serializable data object DismissLastNotification : ActionData() { override val id: ActionId = ActionId.DISMISS_MOST_RECENT_NOTIFICATION @@ -1131,4 +1142,38 @@ sealed class ActionData : Comparable { else -> super.compareTo(other) } } + + @Serializable + data class SetVariable(val name: String, val value: Long) : ActionData() { + override val id: ActionId = ActionId.SET_VARIABLE + + override fun compareTo(other: ActionData) = when (other) { + is SetVariable -> compareValuesBy( + this, + other, + { it.name }, + { it.value }, + ) + + else -> super.compareTo(other) + } + } + + @Serializable + data class ModifyVariable(val name: String, val operation: VariableOperation, val value: Long) : + ActionData() { + override val id: ActionId = ActionId.MODIFY_VARIABLE + + override fun compareTo(other: ActionData) = when (other) { + is ModifyVariable -> compareValuesBy( + this, + other, + { it.name }, + { it.operation }, + { it.value }, + ) + + else -> super.compareTo(other) + } + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 90fc5e687f..8acc6af0df 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.actions import android.util.Base64 import androidx.core.net.toUri import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType +import io.github.sds100.keymapper.base.variables.VariableOperation import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError @@ -76,6 +77,10 @@ object ActionDataEntityMapper { ActionEntity.Type.TOAST -> ActionId.TOAST ActionEntity.Type.VIBRATE -> ActionId.VIBRATE + + ActionEntity.Type.SET_VARIABLE -> ActionId.SET_VARIABLE + + ActionEntity.Type.MODIFY_VARIABLE -> ActionId.MODIFY_VARIABLE } return when (actionId) { @@ -562,9 +567,21 @@ object ActionDataEntityMapper { ActionId.ENABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.EnableAuto - ActionId.INCREASE_BRIGHTNESS -> ActionData.Brightness.Increase + ActionId.INCREASE_BRIGHTNESS -> { + val stepPercent = entity.extras.getData( + ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT, + ).valueOrNull()?.toIntOrNull() - ActionId.DECREASE_BRIGHTNESS -> ActionData.Brightness.Decrease + ActionData.Brightness.Increase(stepPercent) + } + + ActionId.DECREASE_BRIGHTNESS -> { + val stepPercent = entity.extras.getData( + ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT, + ).valueOrNull()?.toIntOrNull() + + ActionData.Brightness.Decrease(stepPercent) + } ActionId.TOGGLE_NIGHT_SHIFT -> ActionData.NightShift.Toggle @@ -700,6 +717,10 @@ object ActionDataEntityMapper { ActionId.SHOW_POWER_MENU -> ActionData.ShowPowerMenu + ActionId.SHUTDOWN_DEVICE -> ActionData.ShutdownDevice + + ActionId.REBOOT_DEVICE -> ActionData.RebootDevice + ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionData.DismissLastNotification ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionData.DismissAllNotifications @@ -775,6 +796,39 @@ object ActionDataEntityMapper { ActionData.Vibrate(effect = effect) } + ActionId.SET_VARIABLE -> { + val name = entity.extras.getData(ActionEntity.EXTRA_VARIABLE_NAME) + .valueOrNull() ?: return null + + // toLongOrNull rather than toLong because this can come from a backup file that + // was edited by hand. + val value = entity.extras.getData(ActionEntity.EXTRA_VARIABLE_VALUE) + .valueOrNull()?.toLongOrNull() ?: return null + + ActionData.SetVariable(name = name, value = value) + } + + ActionId.MODIFY_VARIABLE -> { + val name = entity.extras.getData(ActionEntity.EXTRA_VARIABLE_NAME) + .valueOrNull() ?: return null + + val operationString = entity.extras.getData(ActionEntity.EXTRA_VARIABLE_OPERATION) + .valueOrNull() ?: return null + + val operation = try { + VariableOperation.valueOf(operationString) + } catch (_: IllegalArgumentException) { + return null + } + + // toLongOrNull rather than toLong because this can come from a backup file that + // was edited by hand. + val value = entity.extras.getData(ActionEntity.EXTRA_VARIABLE_VALUE) + .valueOrNull()?.toLongOrNull() ?: return null + + ActionData.ModifyVariable(name = name, operation = operation, value = value) + } + ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall ActionId.END_PHONE_CALL -> ActionData.EndCall @@ -1049,6 +1103,8 @@ object ActionDataEntityMapper { is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION is ActionData.Toast -> ActionEntity.Type.TOAST is ActionData.Vibrate -> ActionEntity.Type.VIBRATE + is ActionData.SetVariable -> ActionEntity.Type.SET_VARIABLE + is ActionData.ModifyVariable -> ActionEntity.Type.MODIFY_VARIABLE else -> ActionEntity.Type.SYSTEM_ACTION } @@ -1140,6 +1196,10 @@ object ActionDataEntityMapper { is ActionData.Vibrate -> "" + is ActionData.SetVariable -> "" + + is ActionData.ModifyVariable -> "" + is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! @@ -1254,6 +1314,18 @@ object ActionDataEntityMapper { } } + is ActionData.Brightness.Increase -> buildList { + data.stepPercent?.let { + add(EntityExtra(ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT, it.toString())) + } + } + + is ActionData.Brightness.Decrease -> buildList { + data.stepPercent?.let { + add(EntityExtra(ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT, it.toString())) + } + } + is ActionData.Rotation.CycleRotations -> listOf( EntityExtra( ActionEntity.EXTRA_ORIENTATIONS, @@ -1516,6 +1588,17 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_TALKBACK_GESTURE_TYPE, data.gesture.name), ) + is ActionData.SetVariable -> listOf( + EntityExtra(ActionEntity.EXTRA_VARIABLE_NAME, data.name), + EntityExtra(ActionEntity.EXTRA_VARIABLE_VALUE, data.value.toString()), + ) + + is ActionData.ModifyVariable -> listOf( + EntityExtra(ActionEntity.EXTRA_VARIABLE_NAME, data.name), + EntityExtra(ActionEntity.EXTRA_VARIABLE_OPERATION, data.operation.name), + EntityExtra(ActionEntity.EXTRA_VARIABLE_VALUE, data.value.toString()), + ) + else -> emptyList() } @@ -1692,6 +1775,8 @@ object ActionDataEntityMapper { ActionId.CONSUME_KEY_EVENT to "consume_key_event", ActionId.OPEN_SETTINGS to "open_settings", ActionId.SHOW_POWER_MENU to "show_power_menu", + ActionId.SHUTDOWN_DEVICE to "shutdown_device", + ActionId.REBOOT_DEVICE to "reboot_device", ActionId.DISMISS_MOST_RECENT_NOTIFICATION to "dismiss_most_recent_notification", ActionId.DISMISS_ALL_NOTIFICATIONS to "dismiss_all_notifications", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index c892fbffc3..ffc99a0286 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -147,6 +147,8 @@ enum class ActionId { CONSUME_KEY_EVENT, OPEN_SETTINGS, SHOW_POWER_MENU, + SHUTDOWN_DEVICE, + REBOOT_DEVICE, DISMISS_MOST_RECENT_NOTIFICATION, DISMISS_ALL_NOTIFICATIONS, @@ -166,4 +168,7 @@ enum class ActionId { MODIFY_SETTING, TALKBACK_GESTURE, + + SET_VARIABLE, + MODIFY_VARIABLE, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt index df5d5eb93d..3a40f38c50 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt @@ -76,135 +76,152 @@ fun ActionOptionsBottomSheet( // Hide drag handle because other bottom sheets don't have it dragHandle = {}, ) { - val uriHandler = LocalUriHandler.current - val ctx = LocalContext.current - val helpUrl = stringResource(R.string.url_keymap_action_options_guide) val scope = rememberCoroutineScope() - var showCustomNameDialog by rememberSaveable { mutableStateOf(false) } - - if (showCustomNameDialog) { - TextFieldDialog( - title = stringResource(R.string.action_options_custom_name_dialog_title), - submitButtonText = stringResource(R.string.pos_save), - initialText = state.title, - onSubmitClick = { newText -> - callback.onCustomNameChanged(newText) - null - }, - onDismissRequest = { showCustomNameDialog = false }, - ) - } - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Spacer(modifier = Modifier.height(12.dp)) - - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 56.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f, fill = false), - textAlign = TextAlign.Center, - text = state.title, - style = MaterialTheme.typography.headlineMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - softWrap = false, - ) - IconButton(onClick = { showCustomNameDialog = true }) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = stringResource( - R.string.action_options_custom_name_dialog_title, - ), - ) - } + ActionOptionsContent( + state = state, + callback = callback, + onDoneClick = { + scope.launch { + sheetState.hide() + onDismissRequest() } + }, + ) + } +} - IconButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(horizontal = 8.dp), - onClick = { uriHandler.openUriSafe(ctx, helpUrl) }, - ) { +/** + * The content of [ActionOptionsBottomSheet] without the sheet window so it can be drawn directly. + */ +@Composable +fun ActionOptionsContent( + modifier: Modifier = Modifier, + state: ActionOptionsState, + callback: ActionOptionsBottomSheetCallback, + onDoneClick: () -> Unit = {}, +) { + val uriHandler = LocalUriHandler.current + val ctx = LocalContext.current + val helpUrl = stringResource(R.string.url_keymap_action_options_guide) + var showCustomNameDialog by rememberSaveable { mutableStateOf(false) } + + if (showCustomNameDialog) { + TextFieldDialog( + title = stringResource(R.string.action_options_custom_name_dialog_title), + submitButtonText = stringResource(R.string.pos_save), + initialText = state.title, + onSubmitClick = { newText -> + callback.onCustomNameChanged(newText) + null + }, + onDismissRequest = { showCustomNameDialog = false }, + ) + } + + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + Spacer(modifier = Modifier.height(12.dp)) + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + textAlign = TextAlign.Center, + text = state.title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, + ) + + IconButton(onClick = { showCustomNameDialog = true }) { Icon( - imageVector = Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = null, + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource( + R.string.action_options_custom_name_dialog_title, + ), ) } } - Spacer(modifier = Modifier.height(16.dp)) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 8.dp), + onClick = { uriHandler.openUriSafe(ctx, helpUrl) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = null, + ) + } + } - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = state.actionTypeIcon, - text = stringResource(R.string.action_options_type_header, state.actionTypeTitle), - ) + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(8.dp)) + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = state.actionTypeIcon, + text = stringResource(R.string.action_options_type_header, state.actionTypeTitle), + ) - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (state.showEditButton) { - FilledTonalButton(onClick = callback::onEditClick) { - Text(stringResource(R.string.action_options_customize)) - } - } + Spacer(modifier = Modifier.height(8.dp)) - FilledTonalButton(onClick = callback::onReplaceClick) { - Text(stringResource(R.string.action_options_swap)) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.showEditButton) { + FilledTonalButton(onClick = callback::onEditClick) { + Text(stringResource(R.string.action_options_customize)) } } - if (state.showRepeat) { - Spacer(modifier = Modifier.height(16.dp)) - RepeatOptions(state = state, callback = callback) + FilledTonalButton(onClick = callback::onReplaceClick) { + Text(stringResource(R.string.action_options_swap)) } + } + if (state.showRepeat) { Spacer(modifier = Modifier.height(16.dp)) - BurstOptions(state = state, callback = callback) + RepeatOptions(state = state, callback = callback) + } - if (state.showHoldDown) { - Spacer(modifier = Modifier.height(16.dp)) - HoldDownOptions(state = state, callback = callback) - } + Spacer(modifier = Modifier.height(16.dp)) + BurstOptions(state = state, callback = callback) + if (state.showHoldDown) { Spacer(modifier = Modifier.height(16.dp)) + HoldDownOptions(state = state, callback = callback) + } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.End, - ) { - Button( - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Icon( - modifier = Modifier.size(ButtonDefaults.IconSize), - imageVector = Icons.Rounded.Check, - contentDescription = null, - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.button_done)) - } - } + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onDoneClick) { + Icon( + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Rounded.Check, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.button_done)) + } } + + Spacer(modifier = Modifier.height(16.dp)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 6e9e1f0306..06a257c296 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.base.utils.ui.IconInfo import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.TintType import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.variables.VariableOperation import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.InputDeviceUtils @@ -54,24 +55,11 @@ class ActionUiHelper( is ActionData.AppShortcut -> action.shortcutTitle is ActionData.InputKeyEvent -> { - val keyCodeString = if (action.keyCode > KeyEvent.getMaxKeyCode()) { - "Key Code ${action.keyCode}" - } else { - KeyEvent.keyCodeToString(action.keyCode) - } + val keyCodeString = getKeyCodeString(action) // only a key code can be inputted through the shell - val metaStateString = buildString { - for (label in KeyCodeStrings.MODIFIER_LABELS.entries) { - val modifier = label.key - val labelRes = label.value - - if (action.metaState.hasFlag(modifier)) { - append("${getString(labelRes)} + ") - } - } - } + val metaStateString = getMetaStateString(action) if (action.device != null) { val name = action.device.name.ifBlank { @@ -489,13 +477,23 @@ class ActionUiHelper( ActionData.Bluetooth.Toggle -> getString(R.string.action_toggle_bluetooth) - ActionData.Brightness.Decrease -> getString(R.string.action_decrease_brightness) + is ActionData.Brightness.Decrease -> + if (action.stepPercent != null) { + getString(R.string.action_decrease_brightness_with_percent, action.stepPercent) + } else { + getString(R.string.action_decrease_brightness) + } ActionData.Brightness.DisableAuto -> getString(R.string.action_disable_auto_brightness) ActionData.Brightness.EnableAuto -> getString(R.string.action_enable_auto_brightness) - ActionData.Brightness.Increase -> getString(R.string.action_increase_brightness) + is ActionData.Brightness.Increase -> + if (action.stepPercent != null) { + getString(R.string.action_increase_brightness_with_percent, action.stepPercent) + } else { + getString(R.string.action_increase_brightness) + } ActionData.Brightness.ToggleAuto -> getString(R.string.action_toggle_auto_brightness) @@ -758,6 +756,10 @@ class ActionUiHelper( ActionData.ForceStopApp -> getString(R.string.action_force_stop_app) + ActionData.ShutdownDevice -> getString(R.string.action_shutdown_device) + + ActionData.RebootDevice -> getString(R.string.action_reboot_device) + is ActionData.ComposeSms -> getString( R.string.action_compose_sms_description, arrayOf(action.message, action.number), @@ -822,6 +824,21 @@ class ActionUiHelper( val actionLabel = getString(TalkBackGestureStrings.getActionLabel(action.gesture)) getString(R.string.action_talkback_gesture_formatted, actionLabel) } + + is ActionData.SetVariable -> + getString( + R.string.action_set_variable_set, + arrayOf(action.name, action.value.toString()), + ) + + is ActionData.ModifyVariable -> { + val stringRes = when (action.operation) { + VariableOperation.ADD -> R.string.action_set_variable_add + VariableOperation.SUBTRACT -> R.string.action_set_variable_subtract + } + + getString(stringRes, arrayOf(action.name, action.value.toString())) + } } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { @@ -895,22 +912,68 @@ class ActionUiHelper( } } - fun getOptionLabels(keyMap: KeyMap, action: Action) = buildList { - getRepeatDescription(keyMap, action)?.let { add(it) } + private fun getKeyCodeString(action: ActionData.InputKeyEvent): String = + if (action.keyCode > KeyEvent.getMaxKeyCode()) { + "Key Code ${action.keyCode}" + } else { + KeyEvent.keyCodeToString(action.keyCode) + } + + private fun getMetaStateString(action: ActionData.InputKeyEvent): String = buildString { + for (label in KeyCodeStrings.MODIFIER_LABELS.entries) { + if (action.metaState.hasFlag(label.key)) { + append("${getString(label.value)} + ") + } + } + } + + /** + * A concise title for the key map list that describes the repeat and hold down options in the + * name itself, e.g. "Repeat KEYCODE_0" instead of "Input KEYCODE_0 • Repeat until released". + * The repeat rate/delay and hold down duration are omitted. + */ + fun getChipTitle(keyMap: KeyMap, action: Action, showDeviceDescriptors: Boolean): String { + val repeat = keyMap.isRepeatingActionsAllowed() && action.repeat + val holdDown = keyMap.isHoldingDownActionAllowed(action) && action.holdDown + val toggle = action.stopHoldDownWhenTriggerPressedAgain + val pressedAgain = action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN + val limit = when { + action.repeatLimit != null -> action.repeatLimit + action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 + else -> null + } - if (keyMap.isHoldingDownActionAllowed(action) && - action.holdDown && - !action.stopHoldDownWhenTriggerPressedAgain - ) { - add(getString(R.string.flag_hold_down)) + val verb = when { + repeat && holdDown && pressedAgain -> getString(R.string.action_chip_toggle_hold_repeat) + + repeat && holdDown && limit != null -> + getString(R.string.action_chip_hold_repeat_limit, limit) + + repeat && holdDown -> getString(R.string.action_chip_hold_repeat) + + repeat && pressedAgain -> getString(R.string.action_chip_toggle_repeat) + + repeat && limit != null -> getString(R.string.action_chip_repeat_limit, limit) + + repeat -> getString(R.string.action_chip_repeat) + + holdDown && toggle -> getString(R.string.action_chip_toggle_hold) + + holdDown -> getString(R.string.action_chip_hold) + + else -> return getTitle(action, showDeviceDescriptors) } - if (keyMap.isHoldingDownActionAllowed(action) && - action.holdDown && - action.stopHoldDownWhenTriggerPressedAgain + // Replace the "Input" verb with the repeat/hold verb for key events. + val title = if (action.customName.isNullOrBlank() && + action.data is ActionData.InputKeyEvent ) { - add(getString(R.string.flag_hold_down_until_pressed_again)) + getMetaStateString(action.data) + getKeyCodeString(action.data) + } else { + getTitle(action, showDeviceDescriptors) } + + return "$verb $title" } /** diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index bda0aae9dc..347e2b35a2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.outlined.Assistant import androidx.compose.material.icons.outlined.BrightnessAuto import androidx.compose.material.icons.outlined.BrightnessHigh import androidx.compose.material.icons.outlined.BrightnessLow +import androidx.compose.material.icons.outlined.Calculate import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.CallEnd import androidx.compose.material.icons.outlined.CameraAlt @@ -53,6 +54,7 @@ import androidx.compose.material.icons.outlined.PhonelinkRing import androidx.compose.material.icons.outlined.Pinch import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.ScreenLockRotation import androidx.compose.material.icons.outlined.ScreenRotation import androidx.compose.material.icons.outlined.SelectAll @@ -79,6 +81,7 @@ import androidx.compose.material.icons.rounded.BluetoothDisabled import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCut import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.WifiOff @@ -88,6 +91,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.icons.HomeIotDevice import io.github.sds100.keymapper.base.utils.ui.compose.icons.InstantMix import io.github.sds100.keymapper.base.utils.ui.compose.icons.JumpToElement import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyboardPreviousLanguage import io.github.sds100.keymapper.base.utils.ui.compose.icons.MatchWord import io.github.sds100.keymapper.base.utils.ui.compose.icons.NfcOff import io.github.sds100.keymapper.base.utils.ui.compose.icons.TextSelectEnd @@ -113,6 +117,7 @@ object ActionUtils { ActionCategory.DISPLAY -> R.string.action_cat_display ActionCategory.NOTIFICATIONS -> R.string.action_cat_notifications ActionCategory.SPECIAL -> R.string.action_cat_special + ActionCategory.VARIABLES -> R.string.action_cat_variables } fun getCategory(id: ActionId): ActionCategory = when (id) { @@ -235,6 +240,8 @@ object ActionUtils { ActionId.CYCLE_KEYBOARD -> ActionCategory.KEYBOARD ActionId.LOCK_DEVICE -> ActionCategory.INTERFACE ActionId.POWER_ON_OFF_DEVICE -> ActionCategory.INTERFACE + ActionId.SHUTDOWN_DEVICE -> ActionCategory.INTERFACE + ActionId.REBOOT_DEVICE -> ActionCategory.INTERFACE ActionId.SECURE_LOCK_DEVICE -> ActionCategory.INTERFACE ActionId.SHOW_POWER_MENU -> ActionCategory.INTERFACE ActionId.PHONE_CALL -> ActionCategory.TELEPHONY @@ -254,6 +261,8 @@ object ActionUtils { ActionId.MODIFY_SETTING -> ActionCategory.APPS ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL ActionId.TALKBACK_GESTURE -> ActionCategory.INTERFACE + ActionId.SET_VARIABLE -> ActionCategory.VARIABLES + ActionId.MODIFY_VARIABLE -> ActionCategory.VARIABLES } @StringRes @@ -469,6 +478,8 @@ object ActionUtils { ActionId.OPEN_SETTINGS -> R.string.action_open_settings ActionId.SHOW_POWER_MENU -> R.string.action_show_power_menu + ActionId.SHUTDOWN_DEVICE -> R.string.action_shutdown_device + ActionId.REBOOT_DEVICE -> R.string.action_reboot_device ActionId.APP -> R.string.action_open_app @@ -534,6 +545,8 @@ object ActionUtils { ActionId.DISABLE_HOTSPOT -> R.string.action_disable_hotspot ActionId.TALKBACK_GESTURE -> R.string.action_talkback_gesture + ActionId.SET_VARIABLE -> R.string.action_set_variable + ActionId.MODIFY_VARIABLE -> R.string.action_modify_variable } @DrawableRes @@ -816,6 +829,8 @@ object ActionUtils { Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ActionId.SHUTDOWN_DEVICE, ActionId.REBOOT_DEVICE -> true + else -> false } } @@ -1077,9 +1092,9 @@ object ActionUtils { ActionId.SELECT_WORD_AT_CURSOR -> KeyMapperIcons.MatchWord ActionId.SELECT_ALL_TEXT -> Icons.Outlined.SelectAll ActionId.PERFORM_IME_ACTION -> Icons.Outlined.Keyboard - ActionId.SWITCH_KEYBOARD -> Icons.Outlined.Keyboard - ActionId.CYCLE_KEYBOARD_LANGUAGE -> Icons.Outlined.Keyboard - ActionId.CYCLE_KEYBOARD -> Icons.Outlined.Keyboard + ActionId.SWITCH_KEYBOARD -> KeyMapperIcons.KeyboardPreviousLanguage + ActionId.CYCLE_KEYBOARD_LANGUAGE -> Icons.Rounded.Language + ActionId.CYCLE_KEYBOARD -> KeyMapperIcons.KeyboardPreviousLanguage ActionId.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.DISABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeInactive @@ -1093,6 +1108,8 @@ object ActionUtils { ActionId.CONSUME_KEY_EVENT -> Icons.Outlined.Cancel ActionId.OPEN_SETTINGS -> Icons.Outlined.Settings ActionId.SHOW_POWER_MENU -> Icons.Outlined.PowerSettingsNew + ActionId.SHUTDOWN_DEVICE -> Icons.Outlined.PowerSettingsNew + ActionId.REBOOT_DEVICE -> Icons.Outlined.RestartAlt ActionId.APP -> Icons.Rounded.Android ActionId.APP_SHORTCUT -> Icons.AutoMirrored.Outlined.OpenInNew ActionId.KEY_CODE -> Icons.Rounded.Abc @@ -1125,6 +1142,8 @@ object ActionUtils { ActionId.ENABLE_HOTSPOT -> Icons.Outlined.WifiTethering ActionId.DISABLE_HOTSPOT -> Icons.Outlined.WifiTetheringOff ActionId.TALKBACK_GESTURE -> Icons.Outlined.Accessibility + ActionId.SET_VARIABLE -> Icons.Outlined.Calculate + ActionId.MODIFY_VARIABLE -> Icons.Outlined.Calculate } } @@ -1175,10 +1194,14 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.CreateNotification, is ActionData.Toast, is ActionData.Vibrate, + is ActionData.Brightness.Increase, + is ActionData.Brightness.Decrease, is ActionData.InteractUiElement, is ActionData.MoveCursor, is ActionData.ModifySetting, is ActionData.TalkBackGesture, + is ActionData.SetVariable, + is ActionData.ModifyVariable, -> true else -> false diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index 499d51fa94..dbee26bb69 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.material.icons.rounded.FlashlightOn import androidx.compose.material.icons.rounded.MoreTime import androidx.compose.material.icons.rounded.Pinch import androidx.compose.material.icons.rounded.Timelapse -import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults @@ -64,6 +63,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.TimeUtils import io.github.sds100.keymapper.system.camera.CameraLens import kotlinx.coroutines.flow.update @@ -125,7 +125,7 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod } @Composable -private fun ActionsScreen( +fun ActionsScreen( modifier: Modifier = Modifier, state: State, tipModel: OnboardingTipModel? = null, @@ -135,37 +135,11 @@ private fun ActionsScreen( onDelayChange: (String, Int) -> Unit = { _, _ -> }, onRenameAction: (String, String) -> Unit = { _, _ -> }, ) { - var actionToDelete by rememberSaveable { mutableStateOf(null) } var actionToRename by rememberSaveable { mutableStateOf(null) } var actionToSetDelay by rememberSaveable { mutableStateOf(null) } val actions = ((state as? State.Data)?.data as? ConfigActionsState.Loaded)?.actions.orEmpty() - if (actionToDelete != null) { - AlertDialog( - onDismissRequest = { actionToDelete = null }, - title = { - Text(stringResource(R.string.action_list_delete_dialog_title)) - }, - text = { Text(stringResource(R.string.action_list_delete_dialog_text)) }, - confirmButton = { - TextButton( - onClick = { - onRemoveClick(actionToDelete!!) - actionToDelete = null - }, - ) { - Text(stringResource(R.string.action_list_delete_yes)) - } - }, - dismissButton = { - TextButton(onClick = { actionToDelete = null }) { - Text(stringResource(R.string.action_list_delete_cancel)) - } - }, - ) - } - val renameModel = actions.find { it.id == actionToRename } if (renameModel != null) { @@ -206,7 +180,7 @@ private fun ActionsScreen( state = state.data, tipModel = tipModel, callback = callback, - onRemoveClick = { actionToDelete = it }, + onRemoveClick = onRemoveClick, onDelayClick = { actionToSetDelay = it }, onRenameClick = { actionToRename = it }, ) @@ -450,12 +424,10 @@ private fun ActionLinkRow( if (delayBeforeNextAction < 1000) { Text(stringResource(R.string.action_title_wait_ms, delayBeforeNextAction)) } else { - val seconds = delayBeforeNextAction / 1000f - val secondsText = if (seconds % 1f == 0f) { - seconds.toInt().toString() - } else { - String.format(LocalLocale.current.platformLocale, "%.1f", seconds) - } + val secondsText = TimeUtils.formatSeconds( + delayBeforeNextAction, + LocalLocale.current.platformLocale, + ) Text( stringResource( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/BrightnessStepActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/BrightnessStepActionBottomSheet.kt new file mode 100644 index 0000000000..990b3e7845 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/BrightnessStepActionBottomSheet.kt @@ -0,0 +1,127 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +private const val MIN_STEP_PERCENT = 1 +private const val MAX_STEP_PERCENT = 50 +private const val STEP_PERCENT_STEP_SIZE = 1 +internal const val DEFAULT_BRIGHTNESS_STEP_PERCENT = 10 + +data class BrightnessStepActionBottomSheetState( + val actionId: ActionId, + val stepPercent: Int = DEFAULT_BRIGHTNESS_STEP_PERCENT, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BrightnessStepActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.brightnessStepActionState != null) { + BrightnessStepActionBottomSheet( + sheetState = sheetState, + state = delegate.brightnessStepActionState!!, + onDismissRequest = { + delegate.brightnessStepActionState = null + }, + onStepPercentChange = delegate::onBrightnessStepPercentChange, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneBrightnessStepClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BrightnessStepActionBottomSheet( + sheetState: SheetState, + state: BrightnessStepActionBottomSheetState, + onDismissRequest: () -> Unit = {}, + onStepPercentChange: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val titleRes = when (state.actionId) { + ActionId.DECREASE_BRIGHTNESS -> R.string.action_decrease_brightness + else -> R.string.action_increase_brightness + } + + KMBottomSheet( + title = stringResource(titleRes), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + SliderOptionText( + title = stringResource(R.string.brightness_step_percent_label), + value = state.stepPercent.toFloat(), + defaultValue = DEFAULT_BRIGHTNESS_STEP_PERCENT.toFloat(), + valueText = { "${it.roundToInt()}%" }, + onValueChange = { onStepPercentChange(it.roundToInt()) }, + valueRange = MIN_STEP_PERCENT.toFloat()..MAX_STEP_PERCENT.toFloat(), + stepSize = STEP_PERCENT_STEP_SIZE, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun BrightnessStepActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + + BrightnessStepActionBottomSheet( + sheetState = sheetState, + state = BrightnessStepActionBottomSheetState( + actionId = ActionId.INCREASE_BRIGHTNESS, + stepPercent = 25, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun BrightnessStepActionBottomSheetDefaultPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + + BrightnessStepActionBottomSheet( + sheetState = sheetState, + state = BrightnessStepActionBottomSheetState( + actionId = ActionId.DECREASE_BRIGHTNESS, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index 0e5a33371a..be9fb86a90 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -62,6 +62,9 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { ToastActionBottomSheet(delegate) VibrateActionBottomSheet(delegate) StepMediaActionBottomSheet(delegate) + SetVariableActionBottomSheet(delegate.setVariableDelegate) + ModifyVariableActionBottomSheet(delegate.modifyVariableDelegate) + BrightnessStepActionBottomSheet(delegate) PickTalkBackGestureDialog(delegate) } @@ -85,7 +88,7 @@ fun ChooseActionScreen(modifier: Modifier = Modifier, viewModel: ChooseActionVie @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ChooseActionScreen( +fun ChooseActionScreen( modifier: Modifier = Modifier, state: State>, query: String? = null, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt index 1421b9e235..0968acd32e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemGroup import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemModel import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.SystemError @@ -38,6 +39,7 @@ class ChooseActionViewModel @Inject constructor( resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, + variablesUseCase: VariablesUseCase, ) : ViewModel(), ResourceProvider by resourceProvider, DialogProvider by dialogProvider, @@ -58,11 +60,20 @@ class ChooseActionViewModel @Inject constructor( ActionCategory.TELEPHONY, ActionCategory.NOTIFICATIONS, ActionCategory.SPECIAL, + ActionCategory.VARIABLES, ) } val createActionDelegate = - CreateActionDelegate(viewModelScope, useCase, this, this, this, vibrateConfigDelegate) + CreateActionDelegate( + viewModelScope, + useCase, + this, + this, + this, + vibrateConfigDelegate, + variablesUseCase, + ) private val allGroupedListItems: List by lazy { buildListGroups() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt index b384eb3da4..78f22748d5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt @@ -21,6 +21,7 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.KMError @@ -58,6 +59,7 @@ class ConfigActionsViewModel @Inject constructor( resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, + variablesUseCase: VariablesUseCase, ) : ViewModel(), ActionOptionsBottomSheetCallback, ActionListCallback, @@ -69,7 +71,15 @@ class ConfigActionsViewModel @Inject constructor( OnboardingTipDelegate by onboardingTipDelegate { val createActionDelegate = - CreateActionDelegate(viewModelScope, createAction, this, this, this, vibrateConfigDelegate) + CreateActionDelegate( + viewModelScope, + createAction, + this, + this, + this, + vibrateConfigDelegate, + variablesUseCase, + ) private val uiHelper = ActionUiHelper(displayAction, resourceProvider) private val _state = MutableStateFlow>(State.Loading) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index f58bd83d0b..d757e3c508 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -22,6 +22,7 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.MultiChoiceItem import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.State @@ -54,6 +55,7 @@ class CreateActionDelegate( navigationProvider: NavigationProvider, resourceProvider: ResourceProvider, vibrateConfigDelegate: VibrateConfigDelegate, + variablesUseCase: VariablesUseCase, ) : ResourceProvider by resourceProvider, DialogProvider by dialogProvider, NavigationProvider by navigationProvider, @@ -75,6 +77,19 @@ class CreateActionDelegate( by mutableStateOf(null) var toastActionBottomSheetState: ToastActionBottomSheetState? by mutableStateOf(null) var stepMediaActionBottomSheetState: StepMediaActionBottomSheetState? by mutableStateOf(null) + var brightnessStepActionState: BrightnessStepActionBottomSheetState? by mutableStateOf(null) + + val setVariableDelegate = SetVariableActionDelegate( + variablesUseCase, + resourceProvider, + dialogProvider, + ) { action -> actionResult.update { action } } + + val modifyVariableDelegate = ModifyVariableActionDelegate( + variablesUseCase, + resourceProvider, + dialogProvider, + ) { action -> actionResult.update { action } } init { coroutineScope.launch { @@ -421,19 +436,23 @@ class CreateActionDelegate( val action = when (state.actionId) { ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward(durationMs) + ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward(durationMs) + ActionId.STEP_FORWARD_PACKAGE -> ActionData.ControlMediaForApp.StepForward( state.packageName!!, durationMs, state.appName, ) + ActionId.STEP_BACKWARD_PACKAGE -> ActionData.ControlMediaForApp.StepBackward( state.packageName!!, durationMs, state.appName, ) + else -> throw Exception("don't know how to create action for ${state.actionId}") } @@ -441,6 +460,24 @@ class CreateActionDelegate( actionResult.update { action } } + fun onBrightnessStepPercentChange(stepPercent: Int) { + brightnessStepActionState = brightnessStepActionState?.copy(stepPercent = stepPercent) + } + + fun onDoneBrightnessStepClick() { + val state = brightnessStepActionState ?: return + val stepPercent = state.stepPercent.takeIf { it != DEFAULT_BRIGHTNESS_STEP_PERCENT } + + val action = when (state.actionId) { + ActionId.INCREASE_BRIGHTNESS -> ActionData.Brightness.Increase(stepPercent) + ActionId.DECREASE_BRIGHTNESS -> ActionData.Brightness.Decrease(stepPercent) + else -> throw Exception("don't know how to create action for ${state.actionId}") + } + + brightnessStepActionState = null + actionResult.update { action } + } + fun onRequestNotificationPermissionClick() { useCase.requestPermission(Permission.POST_NOTIFICATIONS) } @@ -1100,9 +1137,22 @@ class CreateActionDelegate( ActionId.ENABLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.EnableAuto - ActionId.INCREASE_BRIGHTNESS -> return ActionData.Brightness.Increase + ActionId.INCREASE_BRIGHTNESS, + ActionId.DECREASE_BRIGHTNESS, + -> { + val oldStepPercent = when (oldData) { + is ActionData.Brightness.Increase -> oldData.stepPercent + is ActionData.Brightness.Decrease -> oldData.stepPercent + else -> null + } + + brightnessStepActionState = BrightnessStepActionBottomSheetState( + actionId = actionId, + stepPercent = oldStepPercent ?: DEFAULT_BRIGHTNESS_STEP_PERCENT, + ) - ActionId.DECREASE_BRIGHTNESS -> return ActionData.Brightness.Decrease + return null + } ActionId.TOGGLE_NIGHT_SHIFT -> return ActionData.NightShift.Toggle @@ -1240,6 +1290,10 @@ class CreateActionDelegate( ActionId.SHOW_POWER_MENU -> return ActionData.ShowPowerMenu + ActionId.SHUTDOWN_DEVICE -> return ActionData.ShutdownDevice + + ActionId.REBOOT_DEVICE -> return ActionData.RebootDevice + ActionId.DISABLE_DND_MODE -> return ActionData.DoNotDisturb.Disable ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> return ActionData.DismissLastNotification @@ -1274,6 +1328,16 @@ class CreateActionDelegate( return null } + ActionId.SET_VARIABLE -> { + setVariableDelegate.open(oldData as? ActionData.SetVariable) + return null + } + + ActionId.MODIFY_VARIABLE -> { + modifyVariableDelegate.open(oldData as? ActionData.ModifyVariable) + return null + } + ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt index c7fd7d6c7a..0dc65c6766 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateNotificationActionBottomSheet.kt @@ -1,20 +1,14 @@ package io.github.sds100.keymapper.base.actions 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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.height import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -31,12 +25,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import kotlinx.coroutines.launch @@ -99,8 +93,6 @@ private fun CreateNotificationActionBottomSheet( onTestClick: () -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() - val titleEmptyErrorString = stringResource(R.string.action_create_notification_title_error) val textEmptyErrorString = stringResource(R.string.action_create_notification_text_error) @@ -117,169 +109,136 @@ private fun CreateNotificationActionBottomSheet( } } - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - dragHandle = null, - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.action_create_notification), - style = MaterialTheme.typography.headlineMedium, - ) + KMBottomSheet( + title = stringResource(R.string.action_create_notification), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.title.isBlank()) { + titleError = titleEmptyErrorString + } - if (!state.isPermissionGranted) { - FilledTonalButton( - modifier = Modifier.fillMaxWidth(), - onClick = onRequestPermissionClick, - colors = ButtonDefaults.filledTonalButtonColorsError(), - ) { - Text(stringResource(R.string.modify_setting_grant_permission_button)) - } + if (state.text.isBlank()) { + textError = textEmptyErrorString } - OutlinedTextField( - value = state.title, - onValueChange = onTitleChange, - label = { Text(stringResource(R.string.action_create_notification_title_label)) }, - placeholder = { - Text(stringResource(R.string.action_create_notification_title_hint)) - }, + if (titleError == null && textError == null) { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + if (!state.isPermissionGranted) { + FilledTonalButton( modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = titleError != null, - supportingText = { - if (titleError != null) { - Text( - text = titleError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) + onClick = onRequestPermissionClick, + colors = ButtonDefaults.filledTonalButtonColorsError(), + ) { + Text(stringResource(R.string.modify_setting_grant_permission_button)) + } - OutlinedTextField( - value = state.text, - onValueChange = onTextChange, - label = { Text(stringResource(R.string.action_create_notification_text_label)) }, - placeholder = { - Text(stringResource(R.string.action_create_notification_text_hint)) - }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 10, - isError = textError != null, - supportingText = { - if (textError != null) { - Text( - text = textError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) + Spacer(modifier = Modifier.height(16.dp)) + } - CheckBoxText( - text = stringResource(R.string.action_create_notification_timeout_checkbox), - isChecked = state.timeoutEnabled, - onCheckedChange = onTimeoutEnabledChange, - ) + OutlinedTextField( + value = state.title, + onValueChange = onTitleChange, + label = { Text(stringResource(R.string.action_create_notification_title_label)) }, + placeholder = { + Text(stringResource(R.string.action_create_notification_title_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = titleError != null, + supportingText = { + if (titleError != null) { + Text( + text = titleError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) - if (state.timeoutEnabled) { - val timeoutValueFormat = - stringResource(R.string.action_create_notification_timeout_value) + Spacer(modifier = Modifier.height(16.dp)) - SliderOptionText( - title = stringResource(R.string.action_create_notification_timeout_label), - value = state.timeoutSeconds.toFloat(), - defaultValue = 30f, - valueText = { value -> - timeoutValueFormat.format(value.toInt()) - }, - onValueChange = { onTimeoutChange(it.toInt()) }, - valueRange = MIN_TIMEOUT_SECONDS.toFloat()..MAX_TIMEOUT_SECONDS.toFloat(), - stepSize = TIMEOUT_STEP_SECONDS, - ) - } + OutlinedTextField( + value = state.text, + onValueChange = onTextChange, + label = { Text(stringResource(R.string.action_create_notification_text_label)) }, + placeholder = { + Text(stringResource(R.string.action_create_notification_text_hint)) + }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 10, + isError = textError != null, + supportingText = { + if (textError != null) { + Text( + text = textError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { - var hasError = false + Spacer(modifier = Modifier.height(16.dp)) - if (state.title.isBlank()) { - titleError = titleEmptyErrorString - hasError = true - } + CheckBoxText( + text = stringResource(R.string.action_create_notification_timeout_checkbox), + isChecked = state.timeoutEnabled, + onCheckedChange = onTimeoutEnabledChange, + ) - if (state.text.isBlank()) { - textError = textEmptyErrorString - hasError = true - } + if (state.timeoutEnabled) { + Spacer(modifier = Modifier.height(16.dp)) - if (!hasError) { - onTestClick() - } - }, - enabled = state.isPermissionGranted, - ) { - Text(stringResource(R.string.button_test_create_notification)) - } - } + val timeoutValueFormat = + stringResource(R.string.action_create_notification_timeout_value) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } + SliderOptionText( + title = stringResource(R.string.action_create_notification_timeout_label), + value = state.timeoutSeconds.toFloat(), + defaultValue = 30f, + valueText = { value -> + timeoutValueFormat.format(value.toInt()) + }, + onValueChange = { onTimeoutChange(it.toInt()) }, + valueRange = MIN_TIMEOUT_SECONDS.toFloat()..MAX_TIMEOUT_SECONDS.toFloat(), + stepSize = TIMEOUT_STEP_SECONDS, + ) + } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Button( - modifier = Modifier.weight(1f), - onClick = { - if (state.title.isBlank()) { - titleError = titleEmptyErrorString - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + var hasError = false - if (state.text.isBlank()) { - textError = textEmptyErrorString - } + if (state.title.isBlank()) { + titleError = titleEmptyErrorString + hasError = true + } - if (titleError == null && textError == null) { - onDoneClick() - } - }, - ) { - Text(stringResource(R.string.pos_done)) - } + if (state.text.isBlank()) { + textError = textEmptyErrorString + hasError = true + } + + if (!hasError) { + onTestClick() + } + }, + enabled = state.isPermissionGranted, + ) { + Text(stringResource(R.string.button_test_create_notification)) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt index 1e2b993770..8280e2bd53 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt @@ -4,32 +4,24 @@ import android.os.Build import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility 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.ColumnScope 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.padding 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.rounded.BrightnessMedium import androidx.compose.material.icons.rounded.CameraFront import androidx.compose.material.icons.rounded.FlashlightOff import androidx.compose.material.icons.rounded.FlashlightOn import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Slider @@ -47,6 +39,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSliderThumb import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText @@ -105,7 +98,6 @@ private fun EnableFlashlightActionBottomSheet( onDoneClick = onDoneClick, ) { OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), icon = Icons.Rounded.BrightnessMedium, text = stringResource(R.string.action_config_flashlight_brightness), ) @@ -122,7 +114,6 @@ private fun EnableFlashlightActionBottomSheet( if (errorText != null) { Text( - modifier = Modifier.padding(horizontal = 16.dp), text = errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, @@ -134,7 +125,6 @@ private fun EnableFlashlightActionBottomSheet( val sliderMax = state.lensData[state.selectedLens]!!.maxStrength.toFloat() Row( - modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Slider( @@ -164,7 +154,7 @@ private fun EnableFlashlightActionBottomSheet( } if (errorText == null) { - Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Row { Box(modifier = Modifier.weight(1f)) { TextButton( modifier = Modifier.align(Alignment.TopStart), @@ -203,7 +193,6 @@ private fun EnableFlashlightActionBottomSheet( if (errorText == null) { Row( - modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -287,7 +276,6 @@ private fun ChangeFlashlightStrengthActionBottomSheet( onDoneClick = onDoneClick, ) { OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), icon = Icons.Rounded.BrightnessMedium, text = stringResource(R.string.action_config_flashlight_brightness_factor), ) @@ -295,7 +283,6 @@ private fun ChangeFlashlightStrengthActionBottomSheet( Spacer(modifier = Modifier.height(8.dp)) Row( - modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { val interactionSource = remember { MutableInteractionSource() } @@ -346,93 +333,44 @@ private fun FlashlightActionBottomSheet( onDoneClick: () -> Unit, content: @Composable ColumnScope.() -> Unit, ) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = title, + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column( - modifier = Modifier.verticalScroll(scrollState), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = title, - style = MaterialTheme.typography.headlineMedium, + if (availableLenses.size > 1) { + OptionsHeaderRow( + icon = Icons.Rounded.CameraFront, + text = stringResource(R.string.action_config_flashlight_choose_side), ) Spacer(modifier = Modifier.height(8.dp)) - if (availableLenses.size > 1) { - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.CameraFront, - text = stringResource(R.string.action_config_flashlight_choose_side), + Row { + RadioButtonText( + modifier = Modifier, + text = stringResource(R.string.lens_front), + isSelected = selectedLens == CameraLens.FRONT, + onSelected = { onSelectLens(CameraLens.FRONT) }, + isEnabled = availableLenses.contains(CameraLens.FRONT), ) - 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), - ) - } + 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)) - - content() } Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } - } - - Spacer(Modifier.height(16.dp)) + content() } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt index 27818a788b..b06566bfd5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/HttpRequestBottomSheet.kt @@ -1,22 +1,12 @@ package io.github.sds100.keymapper.base.actions -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.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue @@ -28,20 +18,18 @@ 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.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperDropdownMenu import io.github.sds100.keymapper.system.network.HttpMethod import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @OptIn(ExperimentalMaterial3Api::class) @@ -99,8 +87,6 @@ private fun HttpRequestBottomSheet( onAuthorizationChanged: (String) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() var methodExpanded by rememberSaveable { mutableStateOf(false) } val descriptionEmptyErrorString = @@ -111,183 +97,126 @@ private fun HttpRequestBottomSheet( var descriptionError: String? by rememberSaveable { mutableStateOf(null) } var urlError: String? by rememberSaveable { mutableStateOf(null) } - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - dragHandle = null, - ) { - Column( - modifier = Modifier.verticalScroll(scrollState), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.action_http_request), - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - KeyMapperDropdownMenu( - modifier = Modifier - .wrapContentWidth() - .padding(horizontal = 16.dp), - expanded = methodExpanded, - onExpandedChange = { methodExpanded = it }, - label = { Text(stringResource(R.string.action_http_request_method_label)) }, - selectedValue = state.method, - values = HttpMethod.entries.map { it to it.toString() }, - onValueChanged = onSelectMethod, - - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = state.description, - label = { Text(stringResource(R.string.action_http_request_description_label)) }, - onValueChange = { - descriptionError = null - onDescriptionChanged(it) - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - autoCorrectEnabled = true, - keyboardType = KeyboardType.Uri, - ), - isError = descriptionError != null, - supportingText = { - if (descriptionError != null) { - Text( - text = descriptionError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = state.url, - label = { Text(stringResource(R.string.action_http_request_url_label)) }, - onValueChange = { - urlError = null - onUrlChanged(it) - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - ), - isError = urlError != null, - supportingText = { - if (urlError != null) { - Text( - text = urlError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) + KMBottomSheet( + title = stringResource(R.string.action_http_request), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.description.isBlank()) { + descriptionError = descriptionEmptyErrorString + } - Spacer(modifier = Modifier.height(8.dp)) + if (state.url.isBlank()) { + urlError = urlEmptyErrorString + } - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = state.body, - label = { Text(stringResource(R.string.action_http_request_body_label)) }, - onValueChange = { - onBodyChanged(it) - }, - ) + if (state.url.toHttpUrlOrNull() == null) { + urlError = malformedUrlErrorString + } - Spacer(modifier = Modifier.height(16.dp)) + if (descriptionError == null && urlError == null) { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + KeyMapperDropdownMenu( + modifier = Modifier.wrapContentWidth(), + expanded = methodExpanded, + onExpandedChange = { methodExpanded = it }, + label = { Text(stringResource(R.string.action_http_request_method_label)) }, + selectedValue = state.method, + values = HttpMethod.entries.map { it to it.toString() }, + onValueChanged = onSelectMethod, - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = state.authorizationHeader, - label = { Text(stringResource(R.string.action_http_request_authorization_label)) }, - onValueChange = { - onAuthorizationChanged(it) - }, - supportingText = { - Text(stringResource(R.string.action_http_request_authorization_supporting_text)) - }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - ), - ) + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.description, + label = { Text(stringResource(R.string.action_http_request_description_label)) }, + onValueChange = { + descriptionError = null + onDescriptionChanged(it) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + autoCorrectEnabled = true, + keyboardType = KeyboardType.Uri, + ), + isError = descriptionError != null, + supportingText = { + if (descriptionError != null) { + Text( + text = descriptionError!!, + color = MaterialTheme.colorScheme.error, + ) } + }, + ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Button( - modifier = Modifier.weight(1f), - onClick = { - if (state.description.isBlank()) { - descriptionError = descriptionEmptyErrorString - } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.url, + label = { Text(stringResource(R.string.action_http_request_url_label)) }, + onValueChange = { + urlError = null + onUrlChanged(it) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + isError = urlError != null, + supportingText = { + if (urlError != null) { + Text( + text = urlError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) - if (state.url.isBlank()) { - urlError = urlEmptyErrorString - } + Spacer(modifier = Modifier.height(8.dp)) - if (state.url.toHttpUrlOrNull() == null) { - urlError = malformedUrlErrorString - } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.body, + label = { Text(stringResource(R.string.action_http_request_body_label)) }, + onValueChange = { + onBodyChanged(it) + }, + ) - if (descriptionError == null && urlError == null) { - onDoneClick() - } - }, - ) { - Text(stringResource(R.string.pos_done)) - } - } + Spacer(modifier = Modifier.height(16.dp)) - Spacer(Modifier.height(16.dp)) - } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.authorizationHeader, + label = { Text(stringResource(R.string.action_http_request_authorization_label)) }, + onValueChange = { + onAuthorizationChanged(it) + }, + supportingText = { + Text(stringResource(R.string.action_http_request_authorization_supporting_text)) + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + ), + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt index 74f5f3584f..1e410fa4fe 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifySettingActionBottomSheet.kt @@ -1,20 +1,16 @@ package io.github.sds100.keymapper.base.actions 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.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -33,13 +29,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError import io.github.sds100.keymapper.common.utils.KMError @@ -101,8 +97,6 @@ private fun ModifySettingActionBottomSheet( onRequestPermissionClick: () -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() - val settingKeyEmptyErrorString = stringResource(R.string.modify_setting_key_empty_error) val settingValueEmptyErrorString = stringResource(R.string.modify_setting_value_empty_error) @@ -119,192 +113,161 @@ private fun ModifySettingActionBottomSheet( } } - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - dragHandle = null, - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.modify_setting_bottom_sheet_title), - style = MaterialTheme.typography.headlineMedium, - ) - - KeyMapperSegmentedButtonRow( - modifier = Modifier.fillMaxWidth(), - buttonStates = listOf( - SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), - SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), - SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), - ), - selectedState = state.settingType, - onStateSelected = onSelectSettingType, - ) - - if (!state.isPermissionGranted) { - FilledTonalButton( - modifier = Modifier.fillMaxWidth(), - onClick = onRequestPermissionClick, - colors = ButtonDefaults.filledTonalButtonColorsError(), - ) { - Text(stringResource(R.string.modify_setting_grant_permission_button)) - } + KMBottomSheet( + title = stringResource(R.string.modify_setting_bottom_sheet_title), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.settingKey.isBlank()) { + settingKeyError = settingKeyEmptyErrorString } - Button( - onClick = onChooseExistingClick, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.choose_existing_setting)) + if (state.value.isBlank()) { + settingValueError = settingValueEmptyErrorString } - OutlinedTextField( - value = state.settingKey, - onValueChange = onSettingKeyChange, - label = { Text(stringResource(R.string.modify_setting_key_label)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - isError = settingKeyError != null, - supportingText = { - if (settingKeyError != null) { - Text( - text = settingKeyError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) + if (settingKeyError == null && settingValueError == null) { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + SettingType.SYSTEM to stringResource(R.string.modify_setting_type_system), + SettingType.SECURE to stringResource(R.string.modify_setting_type_secure), + SettingType.GLOBAL to stringResource(R.string.modify_setting_type_global), + ), + selectedState = state.settingType, + onStateSelected = onSelectSettingType, + ) - OutlinedTextField( - value = state.value, - onValueChange = onSettingValueChange, - label = { Text(stringResource(R.string.modify_setting_value_label)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - isError = settingValueError != null, - supportingText = { - if (settingValueError != null) { - Text( - text = settingValueError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) + if (!state.isPermissionGranted) { + Spacer(modifier = Modifier.height(16.dp)) - Row( + FilledTonalButton( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, + onClick = onRequestPermissionClick, + colors = ButtonDefaults.filledTonalButtonColorsError(), ) { - if (state.testResult != null) { - val resultText: String = when (state.testResult) { - is Success -> stringResource(R.string.test_modify_setting_result_ok) - is KMError -> state.testResult.getFullMessage(LocalContext.current) - } + Text(stringResource(R.string.modify_setting_grant_permission_button)) + } + } - val textColor = when (state.testResult) { - is Success -> LocalCustomColorsPalette.current.green - is KMError -> MaterialTheme.colorScheme.error - } + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onChooseExistingClick, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.choose_existing_setting)) + } + + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = state.settingKey, + onValueChange = onSettingKeyChange, + label = { Text(stringResource(R.string.modify_setting_key_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + isError = settingKeyError != null, + supportingText = { + if (settingKeyError != null) { Text( - modifier = Modifier.weight(1f), - text = resultText, - color = textColor, - style = MaterialTheme.typography.bodyMedium, + text = settingKeyError!!, + color = MaterialTheme.colorScheme.error, ) } + }, + ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - OutlinedButton( - onClick = { - var hasError = false + OutlinedTextField( + value = state.value, + onValueChange = onSettingValueChange, + label = { Text(stringResource(R.string.modify_setting_value_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + isError = settingValueError != null, + supportingText = { + if (settingValueError != null) { + Text( + text = settingValueError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) - if (state.settingKey.isBlank()) { - settingKeyError = settingKeyEmptyErrorString - hasError = true - } + Spacer(modifier = Modifier.height(16.dp)) - if (state.value.isBlank()) { - settingValueError = settingValueEmptyErrorString - hasError = true - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + if (state.testResult != null) { + val resultText: String = when (state.testResult) { + is Success -> stringResource(R.string.test_modify_setting_result_ok) + is KMError -> state.testResult.getFullMessage(LocalContext.current) + } - if (!hasError) { - onTestClick() - } - }, - ) { - Text(stringResource(R.string.button_test_modify_setting)) + val textColor = when (state.testResult) { + is Success -> LocalCustomColorsPalette.current.green + is KMError -> MaterialTheme.colorScheme.error } - } - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.modify_setting_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( + Text( modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } + text = resultText, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + ) + } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(16.dp)) - Button( - modifier = Modifier.weight(1f), - onClick = { - if (state.settingKey.isBlank()) { - settingKeyError = settingKeyEmptyErrorString - } - - if (state.value.isBlank()) { - settingValueError = settingValueEmptyErrorString - } - - if (settingKeyError == null && settingValueError == null) { - onDoneClick() - } - }, - ) { - Text(stringResource(R.string.pos_done)) - } + OutlinedButton( + onClick = { + var hasError = false + + if (state.settingKey.isBlank()) { + settingKeyError = settingKeyEmptyErrorString + hasError = true + } + + if (state.value.isBlank()) { + settingValueError = settingValueEmptyErrorString + hasError = true + } + + if (!hasError) { + onTestClick() + } + }, + ) { + Text(stringResource(R.string.button_test_modify_setting)) } } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.modify_setting_disclaimer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifyVariableActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifyVariableActionBottomSheet.kt new file mode 100644 index 0000000000..6b72ec37c4 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifyVariableActionBottomSheet.kt @@ -0,0 +1,183 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices.TABLET +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet +import io.github.sds100.keymapper.base.variables.VariableConfigContent +import io.github.sds100.keymapper.base.variables.VariableOperation +import io.github.sds100.keymapper.base.variables.VariableSuggestion +import kotlinx.coroutines.launch + +data class ModifyVariableActionBottomSheetState( + val name: String = "", + val operation: VariableOperation = VariableOperation.ADD, + val value: String = "1", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModifyVariableActionBottomSheet(delegate: ModifyVariableActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state = delegate.state + val suggestions by delegate.suggestions.collectAsState(emptyList()) + + if (state != null) { + ModifyVariableActionBottomSheet( + sheetState = sheetState, + state = state, + suggestions = suggestions, + onDismissRequest = delegate::onDismiss, + onNameChange = delegate::onNameChange, + onValueChange = delegate::onValueChange, + onCycleOperationClick = delegate::onCycleOperationClick, + onResetClick = delegate::onResetClick, + onResetAllClick = delegate::onResetAllClick, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModifyVariableActionBottomSheet( + sheetState: SheetState, + state: ModifyVariableActionBottomSheetState, + suggestions: List = emptyList(), + onDismissRequest: () -> Unit = {}, + onNameChange: (String) -> Unit = {}, + onValueChange: (String) -> Unit = {}, + onCycleOperationClick: () -> Unit = {}, + onResetClick: (String) -> Unit = {}, + onResetAllClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val nameEmptyError = stringResource(R.string.variable_name_error) + var nameError: String? by rememberSaveable { mutableStateOf(null) } + + if (state.name.isNotBlank() && nameError != null) { + nameError = null + } + + KMBottomSheet( + title = stringResource(R.string.action_modify_variable), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.name.isBlank()) { + nameError = nameEmptyError + } else { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + VariableConfigContent( + modifier = Modifier.fillMaxWidth(), + name = state.name, + onNameChange = onNameChange, + value = state.value, + onValueChange = onValueChange, + suggestions = suggestions, + onSuggestionClick = onNameChange, + onResetClick = onResetClick, + onResetAllClick = onResetAllClick, + onCycleOperationClick = onCycleOperationClick, + nameError = nameError, + operationLabel = getOperationLabel(state.operation), + operationIcon = { + Icon( + imageVector = getOperationIcon(state.operation), + contentDescription = getOperationLabel(state.operation), + ) + }, + ) + } +} + +private fun getOperationIcon(operation: VariableOperation): ImageVector { + return when (operation) { + VariableOperation.ADD -> Icons.Rounded.Add + VariableOperation.SUBTRACT -> Icons.Rounded.Remove + } +} + +@Composable +private fun getOperationLabel(operation: VariableOperation): String { + return when (operation) { + VariableOperation.ADD -> stringResource(R.string.variable_operation_add) + VariableOperation.SUBTRACT -> stringResource(R.string.variable_operation_subtract) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true, device = TABLET) +@Composable +private fun ModifyVariableActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + ModifyVariableActionBottomSheet( + sheetState = sheetState, + state = ModifyVariableActionBottomSheetState( + name = "counter", + operation = VariableOperation.ADD, + value = "1", + ), + suggestions = listOf( + VariableSuggestion("counter", 3), + VariableSuggestion("mode", 1), + VariableSuggestion("hits", 0), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun ModifyVariableActionBottomSheetEmptyPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + ModifyVariableActionBottomSheet( + sheetState = sheetState, + state = ModifyVariableActionBottomSheetState(), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifyVariableActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifyVariableActionDelegate.kt new file mode 100644 index 0000000000..d40adf9a85 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ModifyVariableActionDelegate.kt @@ -0,0 +1,77 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.variables.VariableOperation +import io.github.sds100.keymapper.base.variables.VariableSuggestion +import io.github.sds100.keymapper.base.variables.VariablesUseCase +import io.github.sds100.keymapper.base.variables.filterVariableValue +import kotlinx.coroutines.flow.Flow + +class ModifyVariableActionDelegate( + private val variablesUseCase: VariablesUseCase, + resourceProvider: ResourceProvider, + dialogProvider: DialogProvider, + private val onResult: (ActionData.ModifyVariable) -> Unit, +) : ResourceProvider by resourceProvider, + DialogProvider by dialogProvider { + + var state: ModifyVariableActionBottomSheetState? by mutableStateOf(null) + private set + + val suggestions: Flow> = variablesUseCase.suggestions + + fun open(oldAction: ActionData.ModifyVariable?) { + state = ModifyVariableActionBottomSheetState( + name = oldAction?.name ?: "", + operation = oldAction?.operation ?: VariableOperation.ADD, + value = oldAction?.value?.toString() ?: "1", + ) + } + + fun onDismiss() { + state = null + } + + fun onNameChange(name: String) { + state = state?.copy(name = name.trim()) + } + + fun onValueChange(value: String) { + state = state?.copy(value = value.filterVariableValue()) + } + + fun onCycleOperationClick() { + state = state?.let { + val operation = when (it.operation) { + VariableOperation.ADD -> VariableOperation.SUBTRACT + else -> VariableOperation.ADD + } + it.copy(operation = operation) + } + } + + fun onResetClick(name: String) { + variablesUseCase.reset(name) + } + + fun onResetAllClick() { + variablesUseCase.resetAll() + } + + fun onDoneClick() { + val state = state ?: return + val action = ActionData.ModifyVariable( + name = state.name, + operation = state.operation, + value = state.value.toLongOrNull() ?: 0L, + ) + + onDismiss() + + onResult(action) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 6137fe85e7..c8f671262e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -21,6 +21,7 @@ import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.base.vibration.vibrate import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError @@ -49,6 +50,7 @@ import io.github.sds100.keymapper.system.bluetooth.BluetoothAdapter import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.display.DisplayInfo import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.FileUtils import io.github.sds100.keymapper.system.inputevents.Scancode @@ -123,6 +125,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val systemBridgeConnectionManager: SystemBridgeConnectionManager, private val settingsAdapter: SettingsAdapter, private val vibratorAdapter: VibratorAdapter, + private val variablesUseCase: VariablesUseCase, ) : PerformActionsUseCase { companion object { @@ -255,7 +258,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.Rotation.CycleRotations -> { result = displayAdapter.disableAutoRotate().then { - val currentOrientation = displayAdapter.cachedOrientation + val currentOrientation = getActiveDisplay()!!.rotation val index = action.orientations.indexOf(currentOrientation) @@ -376,7 +379,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.TapScreen -> { - val displaySize = displayAdapter.size + val displaySize = getActiveDisplay()!!.activeSize val point = scaleCoordinate(action.x, action.y, action.screenResolution, displaySize) @@ -384,7 +387,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.SwipeScreen -> { - val displaySize = displayAdapter.size + val displaySize = getActiveDisplay()!!.activeSize val start = scaleCoordinate( action.xStart, action.yStart, @@ -406,7 +409,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.PinchScreen -> { - val displaySize = displayAdapter.size + val displaySize = getActiveDisplay()!!.activeSize val point = scaleCoordinate(action.x, action.y, action.screenResolution, displaySize) val distance = @@ -544,11 +547,19 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.Brightness.Increase -> { - result = displayAdapter.increaseBrightness() + val stepPercent = action.stepPercent ?: DEFAULT_BRIGHTNESS_STEP_PERCENT + result = displayAdapter.increaseBrightness( + stepPercent / 100f, + service.getActiveDisplayId(), + ) } is ActionData.Brightness.Decrease -> { - result = displayAdapter.decreaseBrightness() + val stepPercent = action.stepPercent ?: DEFAULT_BRIGHTNESS_STEP_PERCENT + result = displayAdapter.decreaseBrightness( + stepPercent / 100f, + service.getActiveDisplayId(), + ) } is ActionData.Rotation.ToggleAuto -> { @@ -578,8 +589,10 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.Rotation.SwitchOrientation -> { - if (displayAdapter.cachedOrientation == Orientation.ORIENTATION_180 || - displayAdapter.cachedOrientation == Orientation.ORIENTATION_0 + val currentOrientation = getActiveDisplay()!!.rotation + + if (currentOrientation == Orientation.ORIENTATION_180 || + currentOrientation == Orientation.ORIENTATION_0 ) { result = displayAdapter.setOrientation(Orientation.ORIENTATION_90) } else { @@ -1035,6 +1048,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = success() } + is ActionData.SetVariable -> { + result = variablesUseCase.setValue(action.name, action.value) + } + + is ActionData.ModifyVariable -> { + result = variablesUseCase.modify(action.name, action.operation, action.value) + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() @@ -1088,6 +1109,18 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } } + ActionData.ShutdownDevice -> { + result = systemBridgeConnectionManager.run { systemBridge -> + systemBridge.shutdownDevice() + } + } + + ActionData.RebootDevice -> { + result = systemBridgeConnectionManager.run { systemBridge -> + systemBridge.rebootDevice() + } + } + ActionData.ForceStopApp -> { val packageName = service.activeWindowPackageNames .firstOrNull { @@ -1267,6 +1300,9 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private fun compareIfNonNull(a: T?, b: T?): Boolean { return a != null && b != null && a == b } + + private fun getActiveDisplay(): DisplayInfo? = + displayAdapter.getDisplay(service.getActiveDisplayId()) } /** diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt index f40ef22350..960c7366d7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.actions import android.graphics.Bitmap +import android.view.Display import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider @@ -37,7 +38,12 @@ class ScreenshotPickerDelegate( fun selectedScreenshot(newBitmap: Bitmap) { val newBitmapSize = SizeKM(newBitmap.width, newBitmap.height) - if (!displayAdapter.size.hasSameAspectRatio(newBitmapSize)) { + val matchingAspectRatio = + displayAdapter.displays.value + .flatMap { it.supportedSizes } + .any { it.hasSameAspectRatio(newBitmapSize) } + + if (!matchingAspectRatio) { coroutineScope.launch { val snackBar = DialogModel.SnackBar( message = getString(R.string.toast_incorrect_screenshot_resolution), @@ -63,8 +69,12 @@ class ScreenshotPickerDelegate( * action on a device that has since changed resolution does not stamp the wrong one on * unchanged coordinates. */ - fun screenResolution(): SizeKM = - screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size + fun screenResolution(): SizeKM { + val activeDisplay = + displayAdapter.getDisplay(displayAdapter.activityDisplayId ?: Display.DEFAULT_DISPLAY)!! + + return screenshotResolution.value ?: loadedResolution.value ?: activeDisplay.activeSize + } fun recycle() { _bitmap.value?.recycle() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/SetVariableActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/SetVariableActionBottomSheet.kt new file mode 100644 index 0000000000..4ae86c84f5 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/SetVariableActionBottomSheet.kt @@ -0,0 +1,147 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices.TABLET +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet +import io.github.sds100.keymapper.base.variables.VariableConfigContent +import io.github.sds100.keymapper.base.variables.VariableSuggestion +import kotlinx.coroutines.launch + +data class SetVariableActionBottomSheetState(val name: String = "", val value: String = "1") + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SetVariableActionBottomSheet(delegate: SetVariableActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state = delegate.state + val suggestions by delegate.suggestions.collectAsState(emptyList()) + + if (state != null) { + SetVariableActionBottomSheet( + sheetState = sheetState, + state = state, + suggestions = suggestions, + onDismissRequest = delegate::onDismiss, + onNameChange = delegate::onNameChange, + onValueChange = delegate::onValueChange, + onResetClick = delegate::onResetClick, + onResetAllClick = delegate::onResetAllClick, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SetVariableActionBottomSheet( + sheetState: SheetState, + state: SetVariableActionBottomSheetState, + suggestions: List = emptyList(), + onDismissRequest: () -> Unit = {}, + onNameChange: (String) -> Unit = {}, + onValueChange: (String) -> Unit = {}, + onResetClick: (String) -> Unit = {}, + onResetAllClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val nameEmptyError = stringResource(R.string.variable_name_error) + var nameError: String? by rememberSaveable { mutableStateOf(null) } + + if (state.name.isNotBlank() && nameError != null) { + nameError = null + } + + KMBottomSheet( + title = stringResource(R.string.action_set_variable), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.name.isBlank()) { + nameError = nameEmptyError + } else { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + VariableConfigContent( + modifier = Modifier.fillMaxWidth(), + name = state.name, + onNameChange = onNameChange, + value = state.value, + onValueChange = onValueChange, + suggestions = suggestions, + onSuggestionClick = onNameChange, + onResetClick = onResetClick, + onResetAllClick = onResetAllClick, + nameError = nameError, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true, device = TABLET) +@Composable +private fun SetVariableActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + SetVariableActionBottomSheet( + sheetState = sheetState, + state = SetVariableActionBottomSheetState( + name = "counter", + value = "1", + ), + suggestions = listOf( + VariableSuggestion("counter", 3), + VariableSuggestion("mode", 1), + VariableSuggestion("hits", 0), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun SetVariableActionBottomSheetEmptyPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + SetVariableActionBottomSheet( + sheetState = sheetState, + state = SetVariableActionBottomSheetState(), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/SetVariableActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/SetVariableActionDelegate.kt new file mode 100644 index 0000000000..a5f156cb4f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/SetVariableActionDelegate.kt @@ -0,0 +1,64 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.variables.VariableSuggestion +import io.github.sds100.keymapper.base.variables.VariablesUseCase +import io.github.sds100.keymapper.base.variables.filterVariableValue +import kotlinx.coroutines.flow.Flow + +class SetVariableActionDelegate( + private val variablesUseCase: VariablesUseCase, + resourceProvider: ResourceProvider, + dialogProvider: DialogProvider, + private val onResult: (ActionData.SetVariable) -> Unit, +) : ResourceProvider by resourceProvider, + DialogProvider by dialogProvider { + + var state: SetVariableActionBottomSheetState? by mutableStateOf(null) + private set + + val suggestions: Flow> = variablesUseCase.suggestions + + fun open(oldAction: ActionData.SetVariable?) { + state = SetVariableActionBottomSheetState( + name = oldAction?.name ?: "", + value = oldAction?.value?.toString() ?: "1", + ) + } + + fun onDismiss() { + state = null + } + + fun onNameChange(name: String) { + state = state?.copy(name = name.trim()) + } + + fun onValueChange(value: String) { + state = state?.copy(value = value.filterVariableValue()) + } + + fun onResetClick(name: String) { + variablesUseCase.reset(name) + } + + fun onResetAllClick() { + variablesUseCase.resetAll() + } + + fun onDoneClick() { + val state = state ?: return + val action = ActionData.SetVariable( + name = state.name, + value = state.value.toLongOrNull() ?: 0L, + ) + + onDismiss() + + onResult(action) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt index 1523ebb6cb..f12504399c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -102,7 +102,7 @@ fun ShellCommandActionScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ShellCommandActionScreen( +fun ShellCommandActionScreen( modifier: Modifier = Modifier, state: ShellCommandActionState, onDescriptionChanged: (String) -> Unit = {}, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt index a9c8e3bd12..8c93ea7eaa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt @@ -2,21 +2,15 @@ package io.github.sds100.keymapper.base.actions import android.telephony.SmsManager 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.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState @@ -35,18 +29,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.Success -import kotlinx.coroutines.launch sealed class SmsActionBottomSheetState { abstract val number: String @@ -108,9 +101,6 @@ private fun SmsActionBottomSheet( onMessageChanged: (String) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - val numberEmptyErrorString = stringResource(R.string.error_cant_be_empty) val messageEmptyErrorString = stringResource(R.string.error_cant_be_empty) @@ -122,183 +112,130 @@ private fun SmsActionBottomSheet( is SmsActionBottomSheetState.ComposeSms -> stringResource(R.string.action_compose_sms) } - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - dragHandle = null, - ) { - Column( - modifier = Modifier.verticalScroll(scrollState), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = title, - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = state.number, - label = { Text(stringResource(R.string.hint_create_sms_action_number)) }, - onValueChange = { - numberError = null - onNumberChanged(it) - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Phone, - ), - isError = numberError != null, - supportingText = { - if (numberError != null) { - Text( - text = numberError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = state.message, - label = { Text(stringResource(R.string.hint_create_sms_action_message)) }, - onValueChange = { - messageError = null - onMessageChanged(it) - }, - minLines = 3, - maxLines = 5, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - keyboardType = KeyboardType.Text, - ), - isError = messageError != null, - supportingText = { - if (messageError != null) { - Text( - text = messageError!!, - color = MaterialTheme.colorScheme.error, - ) - } - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - text = stringResource(R.string.warning_sms_charges), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelMedium, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - if (state is SmsActionBottomSheetState.SendSms) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - ) { - when (state.testResult) { - is State.Data -> { - val result = state.testResult.data - - val resultText: String = when (result) { - is Success -> stringResource(R.string.test_sms_result_ok) - is KMError -> result.getFullMessage(LocalContext.current) - } - - val textColor = when (result) { - is Success -> LocalCustomColorsPalette.current.green - is KMError -> MaterialTheme.colorScheme.error - } - - Text( - modifier = Modifier.weight(1f), - text = resultText, - color = textColor, - style = MaterialTheme.typography.bodyMedium, - ) - } + KMBottomSheet( + title = title, + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + var hasError = false - State.Loading -> { - CircularProgressIndicator() - } + if (state.number.isBlank()) { + numberError = numberEmptyErrorString + hasError = true + } - null -> {} - } + if (state.message.isBlank()) { + messageError = messageEmptyErrorString + hasError = true + } - Spacer(modifier = Modifier.width(16.dp)) + if (!hasError) { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.number, + label = { Text(stringResource(R.string.hint_create_sms_action_number)) }, + onValueChange = { + numberError = null + onNumberChanged(it) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + ), + isError = numberError != null, + supportingText = { + if (numberError != null) { + Text( + text = numberError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) - OutlinedButton( - onClick = { - var hasError = false + Spacer(modifier = Modifier.height(8.dp)) - if (state.number.isBlank()) { - numberError = numberEmptyErrorString - hasError = true - } + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.message, + label = { Text(stringResource(R.string.hint_create_sms_action_message)) }, + onValueChange = { + messageError = null + onMessageChanged(it) + }, + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + ), + isError = messageError != null, + supportingText = { + if (messageError != null) { + Text( + text = messageError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) - if (state.message.isBlank()) { - messageError = messageEmptyErrorString - hasError = true - } + Spacer(modifier = Modifier.height(8.dp)) - if (!hasError) { - onTestClick() - } - }, - ) { - Text(stringResource(R.string.button_test_sms)) - } - } - } + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.warning_sms_charges), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) + if (state is SmsActionBottomSheetState.SendSms) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() + when (state.testResult) { + is State.Data -> { + val result = state.testResult.data + + val resultText: String = when (result) { + is Success -> stringResource(R.string.test_sms_result_ok) + is KMError -> result.getFullMessage(LocalContext.current) } - }, - ) { - Text(stringResource(R.string.neg_cancel)) + + val textColor = when (result) { + is Success -> LocalCustomColorsPalette.current.green + is KMError -> MaterialTheme.colorScheme.error + } + + Text( + modifier = Modifier.weight(1f), + text = resultText, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + ) + } + + State.Loading -> { + CircularProgressIndicator() + } + + null -> {} } Spacer(modifier = Modifier.width(16.dp)) - Button( - modifier = Modifier.weight(1f), + OutlinedButton( onClick = { var hasError = false @@ -313,15 +250,13 @@ private fun SmsActionBottomSheet( } if (!hasError) { - onDoneClick() + onTestClick() } }, ) { - Text(stringResource(R.string.pos_done)) + Text(stringResource(R.string.button_test_sms)) } } - - Spacer(modifier = Modifier.height(16.dp)) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt index 80c60f48c4..9c08277a5d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt @@ -1,34 +1,24 @@ package io.github.sds100.keymapper.base.actions -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices.TABLET import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText import kotlin.math.roundToInt import kotlinx.coroutines.launch @@ -83,8 +73,6 @@ private fun StepMediaActionBottomSheet( onDurationChange: (Int) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() - val titleRes = when (state.actionId) { ActionId.STEP_BACKWARD, ActionId.STEP_BACKWARD_PACKAGE -> R.string.action_step_backward_media @@ -92,90 +80,51 @@ private fun StepMediaActionBottomSheet( else -> R.string.action_step_forward_media } - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = stringResource(titleRes), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - textAlign = TextAlign.Center, - text = stringResource(titleRes), - style = MaterialTheme.typography.headlineMedium, - ) - - CheckBoxText( - text = stringResource(R.string.action_step_media_duration_checkbox), - isChecked = state.durationEnabled, - onCheckedChange = onDurationEnabledChange, - ) + CheckBoxText( + text = stringResource(R.string.action_step_media_duration_checkbox), + isChecked = state.durationEnabled, + onCheckedChange = onDurationEnabledChange, + ) - if (state.durationEnabled) { - val durationValueFormat = - stringResource(R.string.action_step_media_duration_value) - - SliderOptionText( - title = stringResource(R.string.action_step_media_duration_label), - value = state.durationSeconds.toFloat(), - defaultValue = 30f, - valueText = { value -> - durationValueFormat.format(value.roundToInt()) - }, - onValueChange = { onDurationChange(it.roundToInt()) }, - valueRange = MIN_DURATION_SECONDS.toFloat()..MAX_DURATION_SECONDS.toFloat(), - stepSize = DURATION_STEP_SECONDS, - ) - } - - Text( - text = stringResource(R.string.action_step_media_duration_not_supported_text), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (state.durationEnabled) { + Spacer(modifier = Modifier.height(16.dp)) + + val durationValueFormat = + stringResource(R.string.action_step_media_duration_value) + + SliderOptionText( + title = stringResource(R.string.action_step_media_duration_label), + value = state.durationSeconds.toFloat(), + defaultValue = 30f, + valueText = { value -> + durationValueFormat.format(value.roundToInt()) + }, + onValueChange = { onDurationChange(it.roundToInt()) }, + valueRange = MIN_DURATION_SECONDS.toFloat()..MAX_DURATION_SECONDS.toFloat(), + stepSize = DURATION_STEP_SECONDS, ) + } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } - } - } + Text( + text = stringResource(R.string.action_step_media_duration_not_supported_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview(showSystemUi = true, device = TABLET) @Composable private fun StepMediaActionBottomSheetPreview() { KeyMapperTheme { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt index 7e766f483f..e0d830ac6a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt @@ -1,19 +1,10 @@ package io.github.sds100.keymapper.base.actions -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue @@ -26,14 +17,13 @@ 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.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import kotlinx.coroutines.launch @@ -75,7 +65,6 @@ private fun ToastActionBottomSheet( onDurationChange: (ActionData.Toast.Duration) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() val messageEmptyError = stringResource(R.string.action_toast_message_error) var messageError: String? by rememberSaveable { mutableStateOf(null) } @@ -85,89 +74,48 @@ private fun ToastActionBottomSheet( } } - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = stringResource(R.string.action_toast), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.message.isBlank()) { + messageError = messageEmptyError + } else { + onDoneClick() + } + }, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.action_toast), - style = MaterialTheme.typography.headlineMedium, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = state.message, - onValueChange = onMessageChange, - label = { Text(stringResource(R.string.action_toast_message_label)) }, - placeholder = { Text(stringResource(R.string.action_toast_message_hint)) }, - singleLine = true, - isError = messageError != null, - supportingText = { - if (messageError != null) { - Text(text = messageError!!, color = MaterialTheme.colorScheme.error) - } - }, - ) - - KeyMapperSegmentedButtonRow( - modifier = Modifier.fillMaxWidth(), - buttonStates = listOf( - ActionData.Toast.Duration.SHORT to - stringResource(R.string.action_toast_duration_short), - ActionData.Toast.Duration.LONG to - stringResource(R.string.action_toast_duration_long), - ), - selectedState = state.duration, - onStateSelected = onDurationChange, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.message, + onValueChange = onMessageChange, + label = { Text(stringResource(R.string.action_toast_message_label)) }, + placeholder = { Text(stringResource(R.string.action_toast_message_hint)) }, + singleLine = true, + isError = messageError != null, + supportingText = { + if (messageError != null) { + Text(text = messageError!!, color = MaterialTheme.colorScheme.error) } + }, + ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Button( - modifier = Modifier.weight(1f), - onClick = { - if (state.message.isBlank()) { - messageError = messageEmptyError - } else { - onDoneClick() - } - }, - ) { - Text(stringResource(R.string.pos_done)) - } - } - } + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + ActionData.Toast.Duration.SHORT to + stringResource(R.string.action_toast_duration_short), + ActionData.Toast.Duration.LONG to + stringResource(R.string.action_toast_duration_long), + ), + selectedState = state.duration, + onStateSelected = onDurationChange, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt index 3fe3e3700d..0b81014e1c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt @@ -1,32 +1,18 @@ package io.github.sds100.keymapper.base.actions -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectMode import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectPickerContent import io.github.sds100.keymapper.base.vibration.VibrateConfigState @@ -70,69 +56,25 @@ private fun VibrateActionBottomSheet( onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() - - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = stringResource(R.string.action_vibrate), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.action_vibrate), - style = MaterialTheme.typography.headlineMedium, - ) - - VibrationEffectPickerContent( - modifier = Modifier.fillMaxWidth(), - mode = state.mode, - onModeChange = onModeChange, - durationMs = state.durationMs, - onDurationChange = onDurationChange, - defaultDurationMs = state.defaultDurationMs, - predefinedType = state.predefinedType, - onPredefinedTypeChange = onPredefinedTypeChange, - isPredefinedSupported = state.isPredefinedSupported, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } - } - } + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = state.mode, + onModeChange = onModeChange, + durationMs = state.durationMs, + onDurationChange = onDurationChange, + defaultDurationMs = state.defaultDurationMs, + predefinedType = state.predefinedType, + onPredefinedTypeChange = onPredefinedTypeChange, + isPredefinedSupported = state.isPredefinedSupported, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt index 28e27bc03c..dec2cd3d80 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt @@ -1,26 +1,14 @@ package io.github.sds100.keymapper.base.actions -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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -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.automirrored.outlined.VolumeUp import androidx.compose.material.icons.outlined.Visibility -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -28,16 +16,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.VolumeStreamStrings import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.system.volume.VolumeStream @@ -97,106 +84,54 @@ private fun VolumeActionBottomSheet( onToggleShowVolumeUi: (Boolean) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() - - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = title, + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column( - modifier = Modifier.verticalScroll(scrollState), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = title, - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(16.dp)) + OptionsHeaderRow( + icon = Icons.AutoMirrored.Outlined.VolumeUp, + text = stringResource(R.string.action_config_volume_stream), + ) - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.AutoMirrored.Outlined.VolumeUp, - text = stringResource(R.string.action_config_volume_stream), - ) + Spacer(modifier = Modifier.height(8.dp)) - Spacer(modifier = Modifier.height(8.dp)) + // Default stream option (null means use system default) + RadioButtonText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.action_config_volume_stream_default), + isSelected = state.volumeStream == null, + onSelected = { onSelectStream(null) }, + ) - // Default stream option (null means use system default) + // Individual stream options + VolumeStream.entries.forEach { stream -> RadioButtonText( modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.action_config_volume_stream_default), - isSelected = state.volumeStream == null, - onSelected = { onSelectStream(null) }, + text = stringResource(VolumeStreamStrings.getLabel(stream)), + isSelected = state.volumeStream == stream, + onSelected = { onSelectStream(stream) }, ) + } - // Individual stream options - VolumeStream.entries.forEach { stream -> - RadioButtonText( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(VolumeStreamStrings.getLabel(stream)), - isSelected = state.volumeStream == stream, - onSelected = { onSelectStream(stream) }, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Outlined.Visibility, - text = stringResource(R.string.action_config_volume_options), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - CheckBoxText( - modifier = Modifier.padding(start = 8.dp), - text = stringResource(R.string.flag_show_volume_dialog), - isChecked = state.showVolumeUi, - onCheckedChange = onToggleShowVolumeUi, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.width(16.dp)) + OptionsHeaderRow( + icon = Icons.Outlined.Visibility, + text = stringResource(R.string.action_config_volume_options), + ) - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } - } + Spacer(modifier = Modifier.height(8.dp)) - Spacer(Modifier.height(16.dp)) - } + CheckBoxText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.flag_show_volume_dialog), + isChecked = state.showVolumeUi, + onCheckedChange = onToggleShowVolumeUi, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt index 1cb4157fa7..f472239fee 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt @@ -34,8 +34,8 @@ fun getDedicatedKeyCodeAction(keyCode: Int): ActionData? = when (keyCode) { KeyEvent.KEYCODE_MEDIA_REWIND -> ActionData.ControlMedia.Rewind KeyEvent.KEYCODE_MEDIA_STEP_FORWARD -> ActionData.ControlMedia.StepForward() KeyEvent.KEYCODE_MEDIA_STEP_BACKWARD -> ActionData.ControlMedia.StepBackward() - KeyEvent.KEYCODE_BRIGHTNESS_UP -> ActionData.Brightness.Increase - KeyEvent.KEYCODE_BRIGHTNESS_DOWN -> ActionData.Brightness.Decrease + KeyEvent.KEYCODE_BRIGHTNESS_UP -> ActionData.Brightness.Increase() + KeyEvent.KEYCODE_BRIGHTNESS_DOWN -> ActionData.Brightness.Decrease() KeyEvent.KEYCODE_VOICE_ASSIST -> ActionData.VoiceAssistant KeyEvent.KEYCODE_ASSIST -> ActionData.DeviceAssistant KeyEvent.KEYCODE_CALL -> ActionData.AnswerCall diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt index 04502671f7..ac0a65d15f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt @@ -2,8 +2,10 @@ package io.github.sds100.keymapper.base.actions.talkback import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription +import android.os.Build import android.os.Handler import io.github.sds100.keymapper.base.system.accessibility.AccessibilityGestureUtils +import io.github.sds100.keymapper.base.system.accessibility.getActiveDisplayIdExt import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success @@ -24,6 +26,10 @@ object TalkbackGesturePerformer { val gestureBuilder = GestureDescription.Builder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + gestureBuilder.setDisplayId(service.getActiveDisplayIdExt()) + } + when (gesture) { TalkBackGestureType.ONE_FINGER_DOUBLE_TAP -> AccessibilityGestureUtils.addMultiFingerTaps( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt index 8c5cee4f1f..f8178ccfc7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt @@ -267,6 +267,12 @@ class BackupManagerImpl @Inject constructor( // The isNot/groupUid extras this replaces were still WIP and unreleased, // so pre-existing key maps just fall back to isNot=false/groupUid=null. JsonMigration(22, 23) { json -> json }, + + // Do nothing. Adds the nullable name column to key maps. + JsonMigration(23, 24) { json -> json }, + + // Do nothing. Adds the variables table. + JsonMigration(24, 25) { json -> json }, ) if (keyMapListJsonArray != null) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt index fb243994b2..fec009d8af 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt @@ -54,8 +54,7 @@ fun ChooseConstraintScreen(modifier: Modifier = Modifier, viewModel: ChooseConst val state by viewModel.groups.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() - TimeConstraintBottomSheet(viewModel) - DisplayResolutionConstraintBottomSheet(viewModel) + HandleConstraintBottomSheets(viewModel.createConstraintDelegate) ChooseConstraintScreen( modifier = modifier, @@ -68,6 +67,13 @@ fun ChooseConstraintScreen(modifier: Modifier = Modifier, viewModel: ChooseConst ) } +@Composable +fun HandleConstraintBottomSheets(delegate: CreateConstraintDelegate) { + TimeConstraintBottomSheet(delegate) + DisplayResolutionConstraintBottomSheet(delegate) + VariableConstraintBottomSheet(delegate.variableDelegate) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChooseConstraintScreen( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt index 175a242a40..4c0758b27e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt @@ -2,18 +2,13 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.StayCurrentPortrait -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.containsQuery import io.github.sds100.keymapper.base.utils.getFullMessage -import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider -import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider @@ -21,19 +16,15 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemGroup import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemModel import io.github.sds100.keymapper.base.utils.ui.showDialog -import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.common.utils.PhysicalOrientation -import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.system.camera.CameraLens -import io.github.sds100.keymapper.system.volume.RingerMode import javax.inject.Inject import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -42,10 +33,11 @@ import kotlinx.serialization.json.Json @HiltViewModel class ChooseConstraintViewModel @Inject constructor( - private val useCase: CreateConstraintUseCase, + useCase: CreateConstraintUseCase, dialogProvider: DialogProvider, navigationProvider: NavigationProvider, resourceProvider: ResourceProvider, + variablesUseCase: VariablesUseCase, ) : ViewModel(), ResourceProvider by resourceProvider, DialogProvider by dialogProvider, @@ -70,12 +62,14 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintCategory.DEVICE, ConstraintCategory.NOTIFICATIONS, ConstraintCategory.TIME, + ConstraintCategory.VARIABLES, ) } - private val returnResult = MutableSharedFlow() + val createConstraintDelegate = + CreateConstraintDelegate(useCase, this, this, this, variablesUseCase) - private val allGroupedListItems: List by lazy { buildListGroups() } + private val allGroupedListItems: List by lazy { buildListGroups(useCase) } val searchQuery = MutableStateFlow(null) @@ -95,71 +89,14 @@ class ChooseConstraintViewModel @Inject constructor( State.Data(groups) }.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) - var timeConstraintState: ConstraintData.Time? by mutableStateOf(null) - - var displayResolutionState: DisplayResolutionSheetState? by mutableStateOf(null) - private set - init { viewModelScope.launch { - returnResult.collect { constraintData -> + createConstraintDelegate.constraintResult.filterNotNull().collect { constraintData -> popBackStackWithResult(Json.encodeToString(constraintData)) } } } - fun onDoneConfigTimeConstraintClick() { - timeConstraintState?.let { constraintData -> - viewModelScope.launch { - returnResult.emit(constraintData) - timeConstraintState = null - } - } - } - - fun onSelectDisplayResolution(resolution: SizeKM) { - displayResolutionState = displayResolutionState?.copy( - isCustom = false, - selectedResolution = resolution, - ) - } - - fun onSelectCustomDisplayResolution() { - displayResolutionState = displayResolutionState?.copy(isCustom = true) - } - - fun onDisplayResolutionWidthChange(width: String) { - displayResolutionState = displayResolutionState?.copy( - widthText = width.filter(Char::isDigit), - ) - } - - fun onDisplayResolutionHeightChange(height: String) { - displayResolutionState = displayResolutionState?.copy( - heightText = height.filter(Char::isDigit), - ) - } - - fun onDismissDisplayResolution() { - displayResolutionState = null - } - - fun onDoneConfigDisplayResolutionClick() { - val state = displayResolutionState ?: return - - val resolution = state.resolvedResolution ?: return - - viewModelScope.launch { - returnResult.emit( - ConstraintData.DisplayResolution( - width = resolution.width, - height = resolution.height, - ), - ) - displayResolutionState = null - } - } - fun onNavigateBack() { viewModelScope.launch { popBackStack() @@ -181,176 +118,8 @@ class ChooseConstraintViewModel @Inject constructor( } } - when (val constraintType = ConstraintId.valueOf(id)) { - ConstraintId.APP_IN_FOREGROUND, - ConstraintId.APP_PLAYING_MEDIA, - -> onSelectAppConstraint(constraintType) - - ConstraintId.MEDIA_PLAYING -> returnResult.emit(ConstraintData.MediaPlaying) - - ConstraintId.NOTIFICATION_POSTED -> onSelectNotificationConstraint() - - ConstraintId.BT_DEVICE_CONNECTED -> onSelectBluetoothConstraint() - - ConstraintId.SCREEN_ON -> returnResult.emit(ConstraintData.ScreenOn) - - ConstraintId.DISPLAY_RESOLUTION -> { - displayResolutionState = buildDisplayResolutionState() - } - - ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> - returnResult.emit(ConstraintData.OrientationPortrait) - - ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE -> - returnResult.emit(ConstraintData.OrientationLandscape) - - ConstraintId.DISPLAY_ORIENTATION_0 -> - returnResult.emit( - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_0), - ) - - ConstraintId.DISPLAY_ORIENTATION_90 -> - returnResult.emit( - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_90), - ) - - ConstraintId.DISPLAY_ORIENTATION_180 -> - returnResult.emit( - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_180), - ) - - ConstraintId.DISPLAY_ORIENTATION_270 -> - returnResult.emit( - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_270), - ) - - ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT -> - returnResult.emit( - ConstraintData.PhysicalOrientation( - physicalOrientation = PhysicalOrientation.PORTRAIT, - ), - ) - - ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE -> - returnResult.emit( - ConstraintData.PhysicalOrientation( - physicalOrientation = PhysicalOrientation.LANDSCAPE, - ), - ) - - ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED -> - returnResult.emit( - ConstraintData.PhysicalOrientation( - physicalOrientation = PhysicalOrientation.PORTRAIT_INVERTED, - ), - ) - - ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED -> - returnResult.emit( - ConstraintData.PhysicalOrientation( - physicalOrientation = PhysicalOrientation.LANDSCAPE_INVERTED, - ), - ) - - ConstraintId.FLASHLIGHT_ON -> { - val lens = chooseFlashlightLens() ?: return@launch - returnResult.emit(ConstraintData.FlashlightOn(lens = lens)) - } - - ConstraintId.WIFI_ON -> returnResult.emit(ConstraintData.WifiOn) - - ConstraintId.WIFI_CONNECTED -> onSelectWifiConnectedConstraint() - - ConstraintId.IME_CHOSEN -> onSelectImeChosenConstraint() - - ConstraintId.KEYBOARD_SHOWING -> - returnResult.emit(ConstraintData.KeyboardShowing) - - ConstraintId.DEVICE_IS_LOCKED -> - returnResult.emit(ConstraintData.DeviceIsLocked) - - ConstraintId.IN_PHONE_CALL -> - returnResult.emit(ConstraintData.InPhoneCall) - - ConstraintId.NOT_IN_PHONE_CALL -> - returnResult.emit(ConstraintData.NotInPhoneCall) - - ConstraintId.PHONE_RINGING -> - returnResult.emit(ConstraintData.PhoneRinging) - - ConstraintId.RINGER_MODE_NORMAL -> - returnResult.emit(ConstraintData.RingerMode(RingerMode.NORMAL)) - - ConstraintId.RINGER_MODE_VIBRATE -> - returnResult.emit(ConstraintData.RingerMode(RingerMode.VIBRATE)) - - ConstraintId.RINGER_MODE_SILENT -> - returnResult.emit(ConstraintData.RingerMode(RingerMode.SILENT)) - - ConstraintId.CHARGING -> - returnResult.emit(ConstraintData.Charging) - - ConstraintId.HINGE_CLOSED -> - returnResult.emit(ConstraintData.HingeClosed) - - ConstraintId.HINGE_OPEN -> - returnResult.emit(ConstraintData.HingeOpen) - - ConstraintId.LOCK_SCREEN_SHOWING -> - returnResult.emit(ConstraintData.LockScreenShowing) - - ConstraintId.NOTIFICATION_PANEL_SHOWING -> - returnResult.emit(ConstraintData.NotificationPanelShowing) - - ConstraintId.TIME -> { - timeConstraintState = ConstraintData.Time( - startHour = 0, - startMinute = 0, - endHour = 0, - endMinute = 0, - ) - } - } - } - } - - private fun buildDisplayResolutionState(): DisplayResolutionSheetState { - val supportedResolutions = useCase.getSupportedResolutions() - val currentResolution = useCase.getCurrentResolution() - - val matchingResolution = supportedResolutions.firstOrNull { - it.matchesIgnoringOrientation(currentResolution) + createConstraintDelegate.createConstraint(ConstraintId.valueOf(id)) } - - return DisplayResolutionSheetState( - supportedResolutions = supportedResolutions.sortedBy { it.width }, - // Show the text fields immediately when there is nothing meaningful to pick - // from or when the current resolution isn't one of the supported modes. - isCustom = supportedResolutions.size <= 1 || matchingResolution == null, - selectedResolution = matchingResolution ?: supportedResolutions.firstOrNull(), - widthText = currentResolution.width.toString(), - heightText = currentResolution.height.toString(), - ) - } - - private suspend fun chooseFlashlightLens(): CameraLens? { - val items = useCase.getFlashlightLenses().map { lens -> - val label = when (lens) { - CameraLens.FRONT -> R.string.lens_front - CameraLens.BACK -> R.string.lens_back - } - lens to getString(label) - } - - if (items.size == 1) { - return items.first().first - } - - val dialog = DialogModel.SingleChoice(items) - - val cameraLens = showDialog("choose_flashlight_lens", dialog) - - return cameraLens } private suspend fun onSelectDisplayOrientationConstraint() { @@ -372,27 +141,7 @@ class ChooseConstraintViewModel @Inject constructor( val dialog = DialogModel.SingleChoice(items) val selectedOrientation = showDialog("choose_display_orientation", dialog) ?: return - val constraintData = when (selectedOrientation) { - ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> ConstraintData.OrientationPortrait - - ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE -> ConstraintData.OrientationLandscape - - ConstraintId.DISPLAY_ORIENTATION_0 -> - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_0) - - ConstraintId.DISPLAY_ORIENTATION_90 -> - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_90) - - ConstraintId.DISPLAY_ORIENTATION_180 -> - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_180) - - ConstraintId.DISPLAY_ORIENTATION_270 -> - ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_270) - - else -> return - } - - returnResult.emit(constraintData) + createConstraintDelegate.createConstraint(selectedOrientation) } private suspend fun onSelectPhysicalOrientationConstraint() { @@ -410,254 +159,110 @@ class ChooseConstraintViewModel @Inject constructor( val dialog = DialogModel.SingleChoice(items) val selectedOrientation = showDialog("choose_physical_orientation", dialog) ?: return - returnResult.emit( - ConstraintData.PhysicalOrientation(physicalOrientation = selectedOrientation), - ) - } - - private fun buildListGroups(): List = buildList { - // Filter out individual orientation constraints - show only the consolidated ones - val filteredConstraints = ConstraintId.entries.filter { constraintId -> - constraintId !in listOf( - ConstraintId.DISPLAY_ORIENTATION_PORTRAIT, - ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE, - ConstraintId.DISPLAY_ORIENTATION_0, - ConstraintId.DISPLAY_ORIENTATION_90, - ConstraintId.DISPLAY_ORIENTATION_180, - ConstraintId.DISPLAY_ORIENTATION_270, - ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT, - ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE, - ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED, - ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED, - ) + val constraintId = when (selectedOrientation) { + PhysicalOrientation.PORTRAIT -> ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT + PhysicalOrientation.LANDSCAPE -> ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE + PhysicalOrientation.PORTRAIT_INVERTED -> + ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED + PhysicalOrientation.LANDSCAPE_INVERTED -> + ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED } - val listItems = buildListItems(filteredConstraints) - - // Add synthetic orientation list items - val displayOrientationItem = SimpleListItemModel( - id = DISPLAY_ORIENTATION_LIST_ITEM_ID, - title = getString(R.string.constraint_choose_screen_orientation), - icon = ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait), - isEnabled = true, - ) - - val physicalOrientationItem = SimpleListItemModel( - id = PHYSICAL_ORIENTATION_LIST_ITEM_ID, - title = getString(R.string.constraint_choose_physical_orientation), - icon = ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait), - isEnabled = true, - ) - - for (category in CATEGORY_ORDER) { - val header = getString(ConstraintUtils.getCategoryLabel(category)) - - val categoryItems = listItems.filter { item -> - item.isEnabled && - try { - ConstraintUtils.getCategory(ConstraintId.valueOf(item.id)) == category - } catch (e: IllegalArgumentException) { - false - } - }.toMutableList() - - // Add synthetic orientation items to DISPLAY category - if (category == ConstraintCategory.DISPLAY) { - categoryItems.add(displayOrientationItem) - categoryItems.add(physicalOrientationItem) - } - - val group = SimpleListItemGroup( - header, - items = categoryItems, - ) - - if (group.items.isNotEmpty()) { - add(group) - } - } - - val unsupportedItems = listItems.filter { !it.isEnabled } - if (unsupportedItems.isNotEmpty()) { - val unsupportedGroup = SimpleListItemGroup( - header = getString(R.string.choose_constraint_group_unsupported), - items = unsupportedItems, - ) - add(unsupportedGroup) - } + createConstraintDelegate.createConstraint(constraintId) } - private fun buildListItems(constraintIds: List): List = + private fun buildListGroups(useCase: CreateConstraintUseCase): List = buildList { - for (constraintId in constraintIds) { - val title = getString(ConstraintUtils.getTitleStringId(constraintId)) - val icon = ConstraintUtils.getIcon(constraintId) - val error = useCase.isSupported(constraintId) - - val listItem = SimpleListItemModel( - id = constraintId.toString(), - title = title, - icon = icon, - subtitle = error?.getFullMessage(this@ChooseConstraintViewModel), - isSubtitleError = true, - isEnabled = error == null, + // Filter out individual orientation constraints - show only the consolidated ones + val filteredConstraints = ConstraintId.entries.filter { constraintId -> + constraintId !in listOf( + ConstraintId.DISPLAY_ORIENTATION_PORTRAIT, + ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE, + ConstraintId.DISPLAY_ORIENTATION_0, + ConstraintId.DISPLAY_ORIENTATION_90, + ConstraintId.DISPLAY_ORIENTATION_180, + ConstraintId.DISPLAY_ORIENTATION_270, + ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT, + ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE, + ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED, + ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED, ) - - add(listItem) } - } - - private suspend fun onSelectWifiConnectedConstraint() { - val knownSSIDs: List = useCase.getKnownWiFiSSIDs() - - val chosenSSID: String? - - val savedWifiSSIDs: List = useCase.getSavedWifiSSIDs().first() - - val ssidEntries = buildList { - addAll(savedWifiSSIDs) - addAll(knownSSIDs) - }.distinct() - - val dialog = DialogModel.Text( - hint = getString(R.string.hint_wifi_ssid), - allowEmpty = true, - message = getString(R.string.constraint_wifi_message_cant_list_networks), - autoCompleteEntries = ssidEntries, - ) - - val ssidText = showDialog("type_ssid", dialog) ?: return - if (ssidText.isBlank()) { - chosenSSID = null - } else { - chosenSSID = ssidText + val listItems = buildListItems(useCase, filteredConstraints) - useCase.saveWifiSSID(chosenSSID) - } - - returnResult.emit(ConstraintData.WifiConnected(ssid = chosenSSID)) - } - - private suspend fun onSelectImeChosenConstraint() { - val inputMethods = useCase.getEnabledInputMethods() - val items = inputMethods.map { it.id to it.label } - val dialog = DialogModel.SingleChoice(items = items) - - val result = showDialog("choose_input_method", dialog) ?: return - - val imeInfo = inputMethods.single { it.id == result } - - returnResult.emit( - ConstraintData.ImeChosen( - imeId = imeInfo.id, - imeLabel = imeInfo.label, - ), - ) - } - - private suspend fun onSelectBluetoothConstraint() { - val response = showDialog( - "bluetooth_device_constraint_limitation", - DialogModel.Ok(getString(R.string.dialog_message_bt_constraint_limitation)), - ) - - response ?: return - - val device = navigate( - "choose_bluetooth_device_for_constraint", - NavDestination.ChooseBluetoothDevice, - ) ?: return - - returnResult.emit( - ConstraintData.BtDeviceConnected( - bluetoothAddress = device.address, - deviceName = device.name, - ), - ) - } - - private suspend fun onSelectNotificationConstraint() { - val constraintData = navigate( - "config_notification_constraint", - NavDestination.ConfigNotificationConstraint, - ) ?: return - - returnResult.emit(constraintData) - } - - private suspend fun onSelectAppConstraint(type: ConstraintId) { - val result = - navigate( - "choose_package_for_constraint", - NavDestination.ChooseApp(allowHiddenApps = true), + // Add synthetic orientation list items + val displayOrientationItem = SimpleListItemModel( + id = DISPLAY_ORIENTATION_LIST_ITEM_ID, + title = getString(R.string.constraint_choose_screen_orientation), + icon = ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait), + isEnabled = true, ) - ?: return - val constraintData = when (type) { - ConstraintId.APP_IN_FOREGROUND -> ConstraintData.AppInForeground( - packageName = result.packageName, - appName = result.appName, + val physicalOrientationItem = SimpleListItemModel( + id = PHYSICAL_ORIENTATION_LIST_ITEM_ID, + title = getString(R.string.constraint_choose_physical_orientation), + icon = ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait), + isEnabled = true, ) - ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( - packageName = result.packageName, - appName = result.appName, - ) + for (category in CATEGORY_ORDER) { + val header = getString(ConstraintUtils.getCategoryLabel(category)) + + val categoryItems = listItems.filter { item -> + item.isEnabled && + try { + ConstraintUtils.getCategory(ConstraintId.valueOf(item.id)) == category + } catch (e: IllegalArgumentException) { + false + } + }.toMutableList() + + // Add synthetic orientation items to DISPLAY category + if (category == ConstraintCategory.DISPLAY) { + categoryItems.add(displayOrientationItem) + categoryItems.add(physicalOrientationItem) + } - else -> throw IllegalArgumentException( - "Don't know how to create $type constraint after choosing app", - ) - } + val group = SimpleListItemGroup( + header, + items = categoryItems, + ) - returnResult.emit(constraintData) - } -} + if (group.items.isNotEmpty()) { + add(group) + } + } -/** - * State for the display resolution bottom sheet. - * - * @param supportedResolutions the resolutions the display reports as supported. - * @param isCustom whether the user is entering a custom resolution instead of picking a chip. - * @param selectedResolution the currently selected supported resolution, if any. - * @param widthText the custom width input. - * @param heightText the custom height input. - */ -data class DisplayResolutionSheetState( - val supportedResolutions: List, - val isCustom: Boolean, - val selectedResolution: SizeKM?, - val widthText: String, - val heightText: String, -) { - private val customWidth: Int? get() = widthText.toIntOrNull() - private val customHeight: Int? get() = heightText.toIntOrNull() - - /** - * The resolution that will be saved, or null when the current input is not valid. - */ - val resolvedResolution: SizeKM? - get() = if (isCustom) { - val width = customWidth - val height = customHeight - - if (width != null && width > 0 && height != null && height > 0) { - SizeKM(width, height) - } else { - null + val unsupportedItems = listItems.filter { !it.isEnabled } + if (unsupportedItems.isNotEmpty()) { + val unsupportedGroup = SimpleListItemGroup( + header = getString(R.string.choose_constraint_group_unsupported), + items = unsupportedItems, + ) + add(unsupportedGroup) } - } else { - selectedResolution } - val isValid: Boolean get() = resolvedResolution != null -} + private fun buildListItems( + useCase: CreateConstraintUseCase, + constraintIds: List, + ): List = buildList { + for (constraintId in constraintIds) { + val title = getString(ConstraintUtils.getTitleStringId(constraintId)) + val icon = ConstraintUtils.getIcon(constraintId) + val error = useCase.isSupported(constraintId) + + val listItem = SimpleListItemModel( + id = constraintId.toString(), + title = title, + icon = icon, + subtitle = error?.getFullMessage(this@ChooseConstraintViewModel), + isSubtitleError = true, + isEnabled = error == null, + ) -/** - * Compares two resolutions ignoring orientation so that e.g. 1080x1920 and 1920x1080 - * are treated as the same resolution. - */ -private fun SizeKM.matchesIgnoringOrientation(other: SizeKM): Boolean { - return (width == other.width && height == other.height) || - (width == other.height && height == other.width) + add(listItem) + } + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt index c687b32380..d8aa27ee4f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt @@ -108,6 +108,24 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( } } + override fun setConstraintData(uid: String, data: ConstraintData) { + updateConstraintState { oldState -> + val groups = oldState.groups.map { group -> + val constraints = group.constraints.map { constraint -> + if (constraint.uid == uid) { + constraint.copy(data = data) + } else { + constraint + } + } + + group.copy(constraints = constraints) + } + + oldState.copy(groups = groups) + } + } + override fun removeConstraint(uid: String) { updateConstraintState { oldState -> val groups = oldState.groups @@ -231,6 +249,7 @@ interface ConfigConstraintsUseCase { * group), or null if the group already contains the constraint (a duplicate). */ fun addConstraint(groupUid: String?, constraintData: ConstraintData): ConstraintGroup? + fun setConstraintData(uid: String, data: ConstraintData) fun removeConstraint(uid: String) fun removeGroup(groupUid: String) fun toggleNot(constraintUid: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt index d0fa71f44f..c56ec390b7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull @@ -41,9 +42,11 @@ import kotlinx.coroutines.launch class ConfigConstraintsViewModel @Inject constructor( private val config: ConfigConstraintsUseCase, private val displayConstraint: DisplayConstraintUseCase, + createConstraint: CreateConstraintUseCase, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, + variablesUseCase: VariablesUseCase, ) : ViewModel(), ResourceProvider by resourceProvider, DialogProvider by dialogProvider, @@ -51,6 +54,17 @@ class ConfigConstraintsViewModel @Inject constructor( private val uiHelper = ConstraintUiHelper(displayConstraint, resourceProvider) + val createConstraintDelegate = + CreateConstraintDelegate( + createConstraint, + this, + this, + this, + variablesUseCase, + ) + + private var editedConstraintUid: String? = null + private val _state: MutableStateFlow> = MutableStateFlow(State.Loading) val state = _state.asStateFlow() @@ -75,6 +89,13 @@ class ConfigConstraintsViewModel @Inject constructor( private val knownGroupUids: MutableSet = mutableSetOf() init { + viewModelScope.launch { + createConstraintDelegate.constraintResult.filterNotNull().collect { data -> + val uid = editedConstraintUid ?: return@collect + config.setConstraintData(uid, data) + } + } + viewModelScope.launch { config.keyMap .map { state -> state.dataOrNull()?.constraintState?.groups } @@ -122,6 +143,21 @@ class ConfigConstraintsViewModel @Inject constructor( } } + fun onEditClick(constraintUid: String) { + viewModelScope.launch { + val constraint = config.keyMap + .firstOrNull() + ?.dataOrNull() + ?.constraintState + ?.allConstraints + ?.find { it.uid == constraintUid } + ?: return@launch + + editedConstraintUid = constraintUid + createConstraintDelegate.editConstraint(constraint.data) + } + } + fun onRemoveClick(id: String) = config.removeConstraint(id) fun onRemoveGroupClick(groupUid: String) = config.removeGroup(groupUid) @@ -235,6 +271,7 @@ class ConfigConstraintsViewModel @Inject constructor( icon = uiHelper.getIcon(constraint), text = uiHelper.getTitle(constraint), isNot = constraint.isNot, + isEditable = constraint.data.isEditable(), error = error?.getFullMessage(this), isErrorFixable = error?.isFixable ?: true, ) @@ -247,6 +284,7 @@ class ConfigConstraintsViewModel @Inject constructor( ConstraintGroupListItemModel( uid = group.uid, + icon = uiHelper.getIcon(group.constraints.first()), name = group.name, mode = group.mode, constraints = constraints, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt index 46fe472911..da341a10ba 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt @@ -52,6 +52,29 @@ class ConfigNotificationConstraintViewModel @Inject constructor( null } + fun loadOldData(old: ConstraintData.NotificationPosted) { + isEditing = true + + when (old) { + is ConstraintData.NotificationPosted.FromApp -> { + selectedField = NotificationField.PACKAGE + value = old.packageName + } + + is ConstraintData.NotificationPosted.Title -> { + selectedField = NotificationField.TITLE + matchMode = old.matchMode + value = old.text + } + + is ConstraintData.NotificationPosted.Text -> { + selectedField = NotificationField.TEXT + matchMode = old.matchMode + value = old.text + } + } + } + fun onSelectField(field: NotificationField) { selectedField = field diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt index 23aa88983d..c41ddee49d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import io.github.sds100.keymapper.base.variables.VariableComparison import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.volume.RingerMode as SystemRingerMode @@ -201,6 +202,12 @@ sealed class ConstraintData { val startTime: LocalTime by lazy { LocalTime.of(startHour, startMinute) } val endTime: LocalTime by lazy { LocalTime.of(endHour, endMinute) } } + + @Serializable + data class Variable(val name: String, val comparison: VariableComparison, val value: Long) : + ConstraintData() { + override val id: ConstraintId = ConstraintId.VARIABLE + } } @Serializable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt index a41bbb6f88..5dd91f4d13 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt @@ -14,4 +14,5 @@ enum class ConstraintCategory { DEVICE, NOTIFICATIONS, TIME, + VARIABLES, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt index bf2e7a88f8..a3caccb4b8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt @@ -22,4 +22,5 @@ enum class ConstraintDependency { NOTIFICATION_PANEL_STATE, POSTED_NOTIFICATIONS, DISPLAY_RESOLUTIONS, + VARIABLES, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt index 857de3fd2e..8527441a7e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import io.github.sds100.keymapper.base.variables.VariableComparison import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PhysicalOrientation import io.github.sds100.keymapper.common.utils.getKey @@ -250,6 +251,27 @@ object ConstraintEntityMapper { ) } + ConstraintEntity.VARIABLE -> { + val name = entity.extras.getData(ConstraintEntity.EXTRA_VARIABLE_NAME) + .valueOrNull() ?: "" + + val comparisonString = + entity.extras.getData(ConstraintEntity.EXTRA_VARIABLE_COMPARISON).valueOrNull() + + val comparison = try { + VariableComparison.valueOf(comparisonString.orEmpty()) + } catch (_: IllegalArgumentException) { + VariableComparison.EQUALS + } + + // toLongOrNull rather than toLong because a backup file can be edited by hand, + // and throwing here would happen while a key is being handled. + val value = entity.extras.getData(ConstraintEntity.EXTRA_VARIABLE_VALUE) + .valueOrNull()?.toLongOrNull() ?: 0L + + ConstraintData.Variable(name = name, comparison = comparison, value = value) + } + else -> throw Exception( "don't know how to convert constraint entity with type ${entity.type}", ) @@ -542,6 +564,20 @@ object ConstraintEntityMapper { "${constraint.data.endHour}:${constraint.data.endMinute}", ), ) + + is ConstraintData.Variable -> ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.VARIABLE, + EntityExtra(ConstraintEntity.EXTRA_VARIABLE_NAME, constraint.data.name), + EntityExtra( + ConstraintEntity.EXTRA_VARIABLE_COMPARISON, + constraint.data.comparison.name, + ), + EntityExtra( + ConstraintEntity.EXTRA_VARIABLE_VALUE, + constraint.data.value.toString(), + ), + ) } private fun ConstraintEntity.getNotificationMatchMode(): TextMatchMode = TextMatchMode.valueOf( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt index 3e6b70f7a3..08f5425737 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.base.constraints -import android.content.pm.PackageManager import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.system.SystemError @@ -15,10 +14,14 @@ import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter class LazyConstraintErrorSnapshot( private val packageManager: PackageManagerAdapter, private val permissionAdapter: PermissionAdapter, - private val systemFeatureAdapter: SystemFeatureAdapter, + systemFeatureAdapter: SystemFeatureAdapter, private val inputMethodAdapter: InputMethodAdapter, - private val cameraAdapter: CameraAdapter, -) : ConstraintErrorSnapshot { + cameraAdapter: CameraAdapter, +) : ConstraintErrorSnapshot, + IsConstraintSupportedUseCase by IsConstraintSupportedUseCaseImpl( + systemFeatureAdapter, + cameraAdapter, + ) { private val inputMethods by lazy { inputMethodAdapter.inputMethods.value } private val grantedPermissions: MutableMap = mutableMapOf() @@ -35,6 +38,12 @@ class LazyConstraintErrorSnapshot( } override fun getError(constraint: Constraint): KMError? { + val isSupportedError = isSupported(constraint.id) + + if (isSupportedError != null) { + return isSupportedError + } + when (constraint.data) { is ConstraintData.AppInForeground -> return getAppError( @@ -62,10 +71,6 @@ class LazyConstraintErrorSnapshot( } is ConstraintData.BtDeviceConnected -> { - if (!systemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { - return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) - } - if (!isPermissionGranted(Permission.FIND_NEARBY_DEVICES)) { return SystemError.PermissionDenied(Permission.FIND_NEARBY_DEVICES) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt index 2c72ecc45a..fec0a46584 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -46,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo @@ -112,6 +114,7 @@ fun ConstraintGroupItem( onRemoveConstraintClick: (String) -> Unit = {}, onFixConstraintClick: (String) -> Unit = {}, onNotClick: (String) -> Unit = {}, + onEditConstraintClick: (String) -> Unit = {}, onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { @@ -148,6 +151,12 @@ fun ConstraintGroupItem( onMoveUp = onMoveUp, onMoveDown = onMoveDown, headerContent = { expanded -> + if (model.constraints.size == 1 && !isExpanded) { + ConstraintIcon(modifier = Modifier.size(20.dp), icon = model.icon) + + Spacer(Modifier.width(8.dp)) + } + HeaderText( modifier = Modifier .weight(1f) @@ -155,8 +164,7 @@ fun ConstraintGroupItem( title = if (isExpanded) { model.name ?: countBasedTitle } else { - model.name - ?: model.description + model.name ?: model.description }, model = model, isExpanded = expanded, @@ -178,11 +186,33 @@ fun ConstraintGroupItem( onRemoveConstraintClick = onRemoveConstraintClick, onFixConstraintClick = onFixConstraintClick, onNotClick = onNotClick, + onEditConstraintClick = onEditConstraintClick, ) }, ) } +@Composable +private fun ConstraintIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) { + when (icon) { + is ComposeIconInfo.Vector -> Icon( + modifier = modifier, + imageVector = icon.imageVector, + contentDescription = null, + ) + + is ComposeIconInfo.Drawable -> { + val painter = rememberDrawablePainter(icon.drawable) + Icon( + modifier = modifier, + painter = painter, + contentDescription = null, + tint = Color.Unspecified, + ) + } + } +} + @Composable private fun HeaderText( modifier: Modifier = Modifier, @@ -195,7 +225,8 @@ private fun HeaderText( Row(verticalAlignment = Alignment.CenterVertically) { Text( modifier = Modifier - .weight(1f, fill = false).animateContentSize(), + .weight(1f, fill = false) + .animateContentSize(), text = title, style = MaterialTheme.typography.bodyMedium, maxLines = 2, @@ -247,6 +278,7 @@ private fun ExpandedContent( onRemoveConstraintClick: (String) -> Unit, onFixConstraintClick: (String) -> Unit, onNotClick: (String) -> Unit, + onEditConstraintClick: (String) -> Unit, ) { Column(modifier) { Text( @@ -273,6 +305,7 @@ private fun ExpandedContent( onRemoveConstraintClick = onRemoveConstraintClick, onFixConstraintClick = onFixConstraintClick, onNotClick = onNotClick, + onEditConstraintClick = onEditConstraintClick, ) Spacer(Modifier.size(8.dp)) @@ -339,6 +372,7 @@ private fun GroupConstraintList( onRemoveConstraintClick: (String) -> Unit, onFixConstraintClick: (String) -> Unit, onNotClick: (String) -> Unit, + onEditConstraintClick: (String) -> Unit, ) { val linkText = when (mode) { ConstraintMode.AND -> stringResource(R.string.constraint_mode_and) @@ -354,6 +388,7 @@ private fun GroupConstraintList( onRemoveClick = { onRemoveConstraintClick(constraint.id) }, onFixClick = { onFixConstraintClick(constraint.id) }, onNotClick = { onNotClick(constraint.id) }, + onEditClick = { onEditConstraintClick(constraint.id) }, ) if (index < constraints.lastIndex) { @@ -385,6 +420,7 @@ private val previewConstraints = listOf( id = "2", icon = ComposeIconInfo.Vector(Icons.Outlined.Wifi), text = "Wi-Fi is on", + isEditable = true, ), ) @@ -396,6 +432,7 @@ private fun AndExpandedPreview() { ConstraintGroupItem( model = ConstraintGroupListItemModel( uid = "group", + icon = previewConstraints[0].icon, mode = ConstraintMode.AND, constraints = previewConstraints, description = "Flashlight is not on AND Wi-Fi is on", @@ -413,6 +450,7 @@ private fun OrExpandedPreview() { ConstraintGroupItem( model = ConstraintGroupListItemModel( uid = "group", + icon = previewConstraints[0].icon, mode = ConstraintMode.OR, constraints = previewConstraints.map { it.copy(error = null, isNot = false) }, description = "Flashlight is on OR Wi-Fi is on", @@ -429,6 +467,7 @@ private fun SingleExpandedPreview() { ConstraintGroupItem( model = ConstraintGroupListItemModel( uid = "group", + icon = previewConstraints[1].icon, mode = ConstraintMode.AND, constraints = listOf(previewConstraints[1]), description = "Wi-Fi is on", @@ -446,6 +485,7 @@ private fun CollapsedPreview() { ConstraintGroupItem( model = ConstraintGroupListItemModel( uid = "group", + icon = previewConstraints[0].icon, mode = ConstraintMode.AND, constraints = previewConstraints, description = "Flashlight is not on AND Wi-Fi is on", @@ -456,6 +496,25 @@ private fun CollapsedPreview() { } } +@PreviewLightDark +@Preview(widthDp = 300) +@Composable +private fun CollapsedOneConstraintPreview() { + KeyMapperTheme { + ConstraintGroupItem( + model = ConstraintGroupListItemModel( + uid = "group", + icon = previewConstraints[0].icon, + mode = ConstraintMode.AND, + constraints = previewConstraints.take(1), + description = "Flashlight is not on", + ), + isExpanded = false, + isReorderingEnabled = true, + ) + } +} + @Preview @Composable private fun CollapsedErrorPreview() { @@ -463,6 +522,7 @@ private fun CollapsedErrorPreview() { ConstraintGroupItem( model = ConstraintGroupListItemModel( uid = "group", + icon = previewConstraints[0].icon, mode = ConstraintMode.AND, constraints = previewConstraints, description = "Flashlight is not on AND Wi-Fi is on", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt index d3b2014c7a..29b146754c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt @@ -57,4 +57,6 @@ enum class ConstraintId { NOTIFICATION_POSTED, TIME, + + VARIABLE, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt index 0af2d36e3e..f1935ad7a6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt @@ -1,6 +1,8 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,12 +11,15 @@ 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.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll import androidx.compose.material.icons.outlined.FlashlightOn import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard @@ -26,6 +31,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -42,10 +48,11 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton import io.github.sds100.keymapper.base.utils.ui.compose.CompactFilledTonalButton import io.github.sds100.keymapper.base.utils.ui.compose.CompactOutlinedButton import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.ShrinkWrapText +import io.github.sds100.keymapper.base.utils.ui.compose.horizontalFadingEdges import io.github.sds100.keymapper.base.utils.ui.drawable @Composable @@ -56,6 +63,7 @@ fun ConstraintListItem( onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, onNotClick: () -> Unit = {}, + onEditClick: () -> Unit = {}, ) { ElevatedCard( modifier = modifier, @@ -69,141 +77,120 @@ fun ConstraintListItem( CardDefaults.elevatedCardElevation() }, ) { - val listItemModifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(vertical = 4.dp) - - BoxWithConstraints { - when { - maxWidth < 400.dp -> { - ConstraintListItemMedium( - modifier = listItemModifier, - model = model, - onFixClick = onFixClick, - onNotClick = onNotClick, - onRemoveClick = onRemoveClick, - ) - } - - else -> { - ConstraintListItemLarge( - modifier = listItemModifier, - model = model, - onFixClick = onFixClick, - onNotClick = onNotClick, - onRemoveClick = onRemoveClick, - ) - } - } - } + ConstraintListItemContent( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + model = model, + onFixClick = onFixClick, + onNotClick = onNotClick, + onRemoveClick = onRemoveClick, + onEditClick = onEditClick, + ) } } @Composable -private fun ConstraintListItemLarge( +private fun ConstraintListItemContent( modifier: Modifier = Modifier, model: ConstraintListItemModel, onFixClick: () -> Unit, onNotClick: () -> Unit, onRemoveClick: () -> Unit, + onEditClick: () -> Unit, ) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) + BoxWithConstraints { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.widthIn(max = this@BoxWithConstraints.maxWidth / 2), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) - ConstraintIcon(icon = model.icon) + if (this@BoxWithConstraints.maxWidth > 300.dp) { + ConstraintIcon(icon = model.icon) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) + } - Text( - modifier = Modifier.weight(1f), - text = model.text, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + ShrinkWrapText( + text = model.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) - if (model.error != null && model.isErrorFixable) { - CompactErrorButton(onClick = onFixClick) { - Text(stringResource(R.string.button_fix)) + Spacer(Modifier.width(8.dp)) } - } - Spacer(Modifier.width(8.dp)) + val scrollState = rememberScrollState() - NotToggle(isNot = model.isNot, onClick = onNotClick) - - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - IconButton(onClick = onRemoveClick) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(R.string.constraint_list_item_remove), - ) + // Scroll to the end initially so the delete button is always visible without + // needing to scroll. + LaunchedEffect(Unit) { + scrollState.scrollTo(scrollState.maxValue) } - } - } -} - -@Composable -private fun ConstraintListItemMedium( - modifier: Modifier = Modifier, - model: ConstraintListItemModel, - onFixClick: () -> Unit, - onNotClick: () -> Unit, - onRemoveClick: () -> Unit, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) - ConstraintIcon(icon = model.icon) + Row( + modifier = Modifier + .horizontalFadingEdges(scrollState) + .horizontalScroll(scrollState), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + if (model.error != null && model.isErrorFixable) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onFixClick, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Error, + contentDescription = stringResource( + R.string.constraint_list_item_remove, + ), + ) + } - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) + } - Text( - modifier = Modifier.weight(1f), - text = model.text, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - if (model.error != null && model.isErrorFixable) { - IconButton( - modifier = Modifier.size(24.dp), - onClick = onFixClick, - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Icon( - imageVector = Icons.Rounded.Error, - contentDescription = stringResource(R.string.button_fix), - ) - } - } + NotToggle(isNot = model.isNot, onClick = onNotClick) - Spacer(Modifier.width(8.dp)) + if (model.isEditable) { + Spacer(Modifier.width(8.dp)) - NotToggle(isNot = model.isNot, onClick = onNotClick) + IconButton(onClick = onEditClick) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Settings, + contentDescription = stringResource( + R.string.constraint_list_item_edit, + ), + ) + } + } - IconButton(onClick = onRemoveClick) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(R.string.constraint_list_item_remove), - ) + IconButton(onClick = onRemoveClick) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Clear, + contentDescription = stringResource( + R.string.constraint_list_item_remove, + ), + ) + } + } } } } @@ -273,6 +260,7 @@ private fun VectorPreview() { id = "id", icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), text = "Clear all", + isEditable = true, ), ) } @@ -287,10 +275,11 @@ private fun NotErrorPreview() { model = ConstraintListItemModel( id = "id", icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), - text = "Flashlight is on", + text = "Flashlight is on (very very long text)", isNot = true, error = "Permission need to control camera", isErrorFixable = true, + isEditable = true, ), ) } @@ -307,6 +296,7 @@ private fun DrawablePreview() { id = "id", text = "Key Mapper is in foreground", icon = ComposeIconInfo.Drawable(drawable), + isEditable = true, ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt index 0285a23587..29229b7e03 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt @@ -7,12 +7,14 @@ data class ConstraintListItemModel( val icon: ComposeIconInfo, val text: String, val isNot: Boolean = false, + val isEditable: Boolean = false, val error: String? = null, val isErrorFixable: Boolean = true, ) data class ConstraintGroupListItemModel( val uid: String, + val icon: ComposeIconInfo, val name: String? = null, val mode: ConstraintMode, val constraints: List, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt index 1fd23b1d7c..4ca3e84d0f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt @@ -3,6 +3,8 @@ package io.github.sds100.keymapper.base.constraints import android.media.AudioManager import android.os.Build import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService +import io.github.sds100.keymapper.base.variables.VariableComparison +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PhysicalOrientation import io.github.sds100.keymapper.common.utils.SizeKM @@ -11,6 +13,7 @@ import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.display.DisplayInfo import io.github.sds100.keymapper.system.foldable.FoldableAdapter import io.github.sds100.keymapper.system.foldable.HingeState import io.github.sds100.keymapper.system.foldable.isClosed @@ -32,10 +35,10 @@ import java.time.LocalTime * This allows constraints to be checked lazily because some system calls take a significant amount of time. */ class LazyConstraintSnapshot( - accessibilityService: IAccessibilityService, + private val accessibilityService: IAccessibilityService, mediaAdapter: MediaAdapter, devicesAdapter: DevicesAdapter, - displayAdapter: DisplayAdapter, + private val displayAdapter: DisplayAdapter, networkAdapter: NetworkAdapter, private val cameraAdapter: CameraAdapter, inputMethodAdapter: InputMethodAdapter, @@ -45,17 +48,21 @@ class LazyConstraintSnapshot( private val foldableAdapter: FoldableAdapter, volumeAdapter: VolumeAdapter, notificationAdapter: NotificationAdapter, + variablesUseCase: VariablesUseCase, ) : ConstraintSnapshot { private val appInForeground: String? by lazy { accessibilityService.rootNode?.packageName } private val connectedBluetoothDevices: Set by lazy { devicesAdapter.connectedBluetoothDevices.value } - private val orientation: Orientation by lazy { displayAdapter.cachedOrientation } + private val orientation: Orientation by lazy { getActiveDisplay()!!.rotation } private val physicalOrientation: PhysicalOrientation by lazy { displayAdapter.cachedPhysicalOrientation } private val isScreenOn: Boolean by lazy { displayAdapter.isScreenOn.firstBlocking() } - private val displaySize: SizeKM by lazy { displayAdapter.size } + private val displaySize: SizeKM by lazy { + getActiveDisplay()!!.activeSize + } + private val appsPlayingMedia: List by lazy { mediaAdapter.getActiveMediaSessionPackages() } @@ -92,6 +99,8 @@ class LazyConstraintSnapshot( notificationAdapter.activeNotifications.value } + private val variables: Map by lazy { variablesUseCase.getValues() } + private val localTime = LocalTime.now() private fun isMediaPlaying(): Boolean { @@ -227,10 +236,25 @@ class LazyConstraintSnapshot( localTime.isAfter(constraint.data.startTime) && localTime.isBefore(constraint.data.endTime) } + + is ConstraintData.Variable -> { + // A variable that has never been set counts as 0. + val currentValue = variables[constraint.data.name] ?: 0L + + when (constraint.data.comparison) { + VariableComparison.EQUALS -> currentValue == constraint.data.value + VariableComparison.GREATER_THAN -> currentValue > constraint.data.value + VariableComparison.LESS_THAN -> currentValue < constraint.data.value + } + } } return isSatisfied != constraint.isNot } + + private fun getActiveDisplay(): DisplayInfo? { + return displayAdapter.getDisplay(accessibilityService.getActiveDisplayId()) + } } interface ConstraintSnapshot { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index f64f6b1cc8..58d0203aa0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.rounded.Android import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.variables.VariableComparison import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PhysicalOrientation import io.github.sds100.keymapper.common.utils.TimeUtils @@ -162,6 +163,32 @@ class ConstraintUiHelper( timeFormatter.format(constraint.data.endTime), ), ) + + is ConstraintData.Variable -> getVariableTitle(constraint.data, isNot = false) + } + + private fun getVariableTitle(data: ConstraintData.Variable, isNot: Boolean): String { + val stringRes = when (data.comparison) { + VariableComparison.EQUALS -> if (isNot) { + R.string.constraint_variable_not_equals + } else { + R.string.constraint_variable_equals + } + + VariableComparison.GREATER_THAN -> if (isNot) { + R.string.constraint_variable_not_greater_than + } else { + R.string.constraint_variable_greater_than + } + + VariableComparison.LESS_THAN -> if (isNot) { + R.string.constraint_variable_not_less_than + } else { + R.string.constraint_variable_less_than + } + } + + return getString(stringRes, arrayOf(data.name, data.value.toString())) } private fun getNotTitle(constraint: Constraint): String = when (constraint.data) { @@ -256,6 +283,8 @@ class ConstraintUiHelper( ), ) + is ConstraintData.Variable -> getVariableTitle(constraint.data, isNot = true) + is ConstraintData.OrientationCustom -> { val resId = when (constraint.data.orientation) { Orientation.ORIENTATION_0 -> R.string.constraint_not_orientation_0 diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt index dc42af5605..94a3e25749 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.outlined.Battery2Bar import androidx.compose.material.icons.outlined.BatteryChargingFull import androidx.compose.material.icons.outlined.BluetoothConnected import androidx.compose.material.icons.outlined.BluetoothDisabled +import androidx.compose.material.icons.outlined.Calculate import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.CallEnd import androidx.compose.material.icons.outlined.FlashlightOff @@ -33,6 +34,10 @@ import androidx.compose.material.icons.outlined.WifiOff import androidx.compose.material.icons.rounded.Android import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo.Vector +import io.github.sds100.keymapper.base.utils.ui.compose.icons.DualScreen +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.Mobile object ConstraintUtils { @@ -51,6 +56,7 @@ object ConstraintUtils { ConstraintCategory.DEVICE -> R.string.constraint_cat_device ConstraintCategory.NOTIFICATIONS -> R.string.constraint_cat_notifications ConstraintCategory.TIME -> R.string.constraint_cat_time + ConstraintCategory.VARIABLES -> R.string.constraint_cat_variables } fun getCategory(constraintId: ConstraintId): ConstraintCategory = when (constraintId) { @@ -109,127 +115,129 @@ object ConstraintUtils { -> ConstraintCategory.NOTIFICATIONS ConstraintId.TIME -> ConstraintCategory.TIME + ConstraintId.VARIABLE -> ConstraintCategory.VARIABLES } fun getIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) { ConstraintId.APP_IN_FOREGROUND, ConstraintId.APP_PLAYING_MEDIA, - -> ComposeIconInfo.Vector(Icons.Rounded.Android) + -> Vector(Icons.Rounded.Android) - ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.PlayArrow) + ConstraintId.MEDIA_PLAYING -> Vector(Icons.Outlined.PlayArrow) - ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector( + ConstraintId.BT_DEVICE_CONNECTED -> Vector( Icons.Outlined.BluetoothConnected, ) ConstraintId.DISPLAY_ORIENTATION_0, ConstraintId.DISPLAY_ORIENTATION_180, - -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + -> Vector(Icons.Outlined.StayCurrentPortrait) ConstraintId.DISPLAY_ORIENTATION_90, ConstraintId.DISPLAY_ORIENTATION_270, - -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) + -> Vector(Icons.Outlined.StayCurrentLandscape) - ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE -> ComposeIconInfo.Vector( + ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE -> Vector( Icons.Outlined.StayCurrentLandscape, ) - ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> ComposeIconInfo.Vector( + ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> Vector( Icons.Outlined.StayCurrentPortrait, ) ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT, ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED, - -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + -> Vector(Icons.Outlined.StayCurrentPortrait) ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE, ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED, - -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) + -> Vector(Icons.Outlined.StayCurrentLandscape) - ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + ConstraintId.SCREEN_ON -> Vector(Icons.Outlined.StayCurrentPortrait) - ConstraintId.DISPLAY_RESOLUTION -> ComposeIconInfo.Vector(Icons.Outlined.AspectRatio) + ConstraintId.DISPLAY_RESOLUTION -> Vector(Icons.Outlined.AspectRatio) - ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn) + ConstraintId.FLASHLIGHT_ON -> Vector(Icons.Outlined.FlashlightOn) - ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.Wifi) + ConstraintId.WIFI_CONNECTED -> Vector(Icons.Outlined.Wifi) - ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.Wifi) + ConstraintId.WIFI_ON -> Vector(Icons.Outlined.Wifi) - ConstraintId.IME_CHOSEN -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) + ConstraintId.IME_CHOSEN -> Vector(Icons.Outlined.Keyboard) - ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) + ConstraintId.KEYBOARD_SHOWING -> Vector(Icons.Outlined.Keyboard) - ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.Lock) + ConstraintId.DEVICE_IS_LOCKED -> Vector(Icons.Outlined.Lock) - ConstraintId.IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call) + ConstraintId.IN_PHONE_CALL -> Vector(Icons.Outlined.Call) - ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd) + ConstraintId.NOT_IN_PHONE_CALL -> Vector(Icons.Outlined.CallEnd) - ConstraintId.PHONE_RINGING -> ComposeIconInfo.Vector(Icons.Outlined.RingVolume) + ConstraintId.PHONE_RINGING -> Vector(Icons.Outlined.RingVolume) - ConstraintId.RINGER_MODE_NORMAL -> ComposeIconInfo.Vector(Icons.Outlined.Notifications) + ConstraintId.RINGER_MODE_NORMAL -> Vector(Icons.Outlined.Notifications) - ConstraintId.RINGER_MODE_VIBRATE -> ComposeIconInfo.Vector(Icons.Outlined.Vibration) + ConstraintId.RINGER_MODE_VIBRATE -> Vector(Icons.Outlined.Vibration) - ConstraintId.RINGER_MODE_SILENT -> ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff) + ConstraintId.RINGER_MODE_SILENT -> Vector(Icons.Outlined.NotificationsOff) - ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.BatteryChargingFull) + ConstraintId.CHARGING -> Vector(Icons.Outlined.BatteryChargingFull) - ConstraintId.HINGE_CLOSED -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + ConstraintId.HINGE_CLOSED -> Vector(KeyMapperIcons.Mobile) - ConstraintId.HINGE_OPEN -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) + ConstraintId.HINGE_OPEN -> Vector(KeyMapperIcons.DualScreen) - ConstraintId.LOCK_SCREEN_SHOWING -> ComposeIconInfo.Vector( + ConstraintId.LOCK_SCREEN_SHOWING -> Vector( Icons.Outlined.ScreenLockPortrait, ) ConstraintId.NOTIFICATION_PANEL_SHOWING -> - ComposeIconInfo.Vector(Icons.Outlined.Notifications) + Vector(Icons.Outlined.Notifications) ConstraintId.NOTIFICATION_POSTED -> - ComposeIconInfo.Vector(Icons.Outlined.NotificationsActive) + Vector(Icons.Outlined.NotificationsActive) ConstraintId.TIME -> ComposeIconInfo.Vector(Icons.Outlined.Timer) + ConstraintId.VARIABLE -> ComposeIconInfo.Vector(Icons.Outlined.Calculate) } fun getNotIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) { - ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.StopCircle) + ConstraintId.MEDIA_PLAYING -> Vector(Icons.Outlined.StopCircle) - ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector( + ConstraintId.BT_DEVICE_CONNECTED -> Vector( Icons.Outlined.BluetoothDisabled, ) - ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.MobileOff) + ConstraintId.SCREEN_ON -> Vector(Icons.Outlined.MobileOff) - ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOff) + ConstraintId.FLASHLIGHT_ON -> Vector(Icons.Outlined.FlashlightOff) - ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector( + ConstraintId.WIFI_CONNECTED -> Vector( Icons.Outlined.SignalWifiStatusbarNull, ) - ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.WifiOff) + ConstraintId.WIFI_ON -> Vector(Icons.Outlined.WifiOff) - ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.KeyboardHide) + ConstraintId.KEYBOARD_SHOWING -> Vector(Icons.Outlined.KeyboardHide) - ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) + ConstraintId.DEVICE_IS_LOCKED -> Vector(Icons.Outlined.LockOpen) - ConstraintId.LOCK_SCREEN_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) + ConstraintId.LOCK_SCREEN_SHOWING -> Vector(Icons.Outlined.LockOpen) - ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar) + ConstraintId.CHARGING -> Vector(Icons.Outlined.Battery2Bar) - ConstraintId.NOTIFICATION_PANEL_SHOWING -> ComposeIconInfo.Vector( + ConstraintId.NOTIFICATION_PANEL_SHOWING -> Vector( Icons.Outlined.NotificationsOff, ) - ConstraintId.IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd) + ConstraintId.IN_PHONE_CALL -> Vector(Icons.Outlined.CallEnd) - ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call) + ConstraintId.NOT_IN_PHONE_CALL -> Vector(Icons.Outlined.Call) - ConstraintId.PHONE_RINGING -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd) + ConstraintId.PHONE_RINGING -> Vector(Icons.Outlined.CallEnd) ConstraintId.NOTIFICATION_POSTED -> - ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff) + Vector(Icons.Outlined.NotificationsOff) else -> getIcon(constraintId) } @@ -310,6 +318,7 @@ object ConstraintUtils { ConstraintId.NOTIFICATION_POSTED -> R.string.constraint_notification_posted ConstraintId.TIME -> R.string.constraint_time + ConstraintId.VARIABLE -> R.string.constraint_variable } @StringRes @@ -385,6 +394,26 @@ object ConstraintUtils { is ConstraintData.NotificationPosted -> setOf(ConstraintDependency.POSTED_NOTIFICATIONS) + + is ConstraintData.Variable -> setOf(ConstraintDependency.VARIABLES) } } } + +fun ConstraintData.isEditable(): Boolean = when (this) { + is ConstraintData.AppInForeground, + is ConstraintData.AppPlayingMedia, + is ConstraintData.BtDeviceConnected, + is ConstraintData.OrientationCustom, + is ConstraintData.PhysicalOrientation, + is ConstraintData.DisplayResolution, + is ConstraintData.FlashlightOn, + is ConstraintData.WifiConnected, + is ConstraintData.ImeChosen, + is ConstraintData.NotificationPosted, + is ConstraintData.Time, + is ConstraintData.Variable, + -> true + + else -> false +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt index ffe841aba1..ec7cc8a6d3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material.icons.rounded.FlashlightOn -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton @@ -24,7 +23,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -65,6 +63,8 @@ fun ConstraintsScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() + HandleConstraintBottomSheets(viewModel.createConstraintDelegate) + if (viewModel.showDuplicateConstraintsSnackbar) { val message = stringResource(R.string.error_duplicate_constraint) @@ -82,6 +82,7 @@ fun ConstraintsScreen( onRemoveClick = viewModel::onRemoveClick, onRemoveGroupClick = viewModel::onRemoveGroupClick, onNotClick = viewModel::onNotClick, + onEditClick = viewModel::onEditClick, onFixErrorClick = viewModel::onFixError, onClickShortcut = viewModel::onClickShortcut, onSelectMode = viewModel::onSelectMode, @@ -93,7 +94,7 @@ fun ConstraintsScreen( } @Composable -private fun ConstraintsScreen( +fun ConstraintsScreen( modifier: Modifier = Modifier, state: State, onAddClick: () -> Unit = {}, @@ -101,6 +102,7 @@ private fun ConstraintsScreen( onRemoveClick: (String) -> Unit = {}, onRemoveGroupClick: (String) -> Unit = {}, onNotClick: (String) -> Unit = {}, + onEditClick: (String) -> Unit = {}, onFixErrorClick: (String) -> Unit = {}, onClickShortcut: (ConstraintData) -> Unit = {}, onSelectMode: (ConstraintMode) -> Unit = {}, @@ -109,60 +111,8 @@ private fun ConstraintsScreen( onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit = { _, _ -> }, onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, ) { - var constraintToDelete by rememberSaveable { mutableStateOf(null) } - var groupToDelete by rememberSaveable { mutableStateOf(null) } var groupToRename by rememberSaveable { mutableStateOf(null) } - if (constraintToDelete != null) { - AlertDialog( - onDismissRequest = { constraintToDelete = null }, - title = { - Text(stringResource(R.string.constraint_list_delete_dialog_title)) - }, - text = { Text(stringResource(R.string.constraint_list_delete_dialog_text)) }, - confirmButton = { - TextButton( - onClick = { - onRemoveClick(constraintToDelete!!) - constraintToDelete = null - }, - ) { - Text(stringResource(R.string.constraint_list_delete_yes)) - } - }, - dismissButton = { - TextButton(onClick = { constraintToDelete = null }) { - Text(stringResource(R.string.constraint_list_delete_cancel)) - } - }, - ) - } - - if (groupToDelete != null) { - AlertDialog( - onDismissRequest = { groupToDelete = null }, - title = { - Text(stringResource(R.string.constraint_group_delete_dialog_title)) - }, - text = { Text(stringResource(R.string.constraint_group_delete_dialog_text)) }, - confirmButton = { - TextButton( - onClick = { - onRemoveGroupClick(groupToDelete!!) - groupToDelete = null - }, - ) { - Text(stringResource(R.string.constraint_list_delete_yes)) - } - }, - dismissButton = { - TextButton(onClick = { groupToDelete = null }) { - Text(stringResource(R.string.constraint_list_delete_cancel)) - } - }, - ) - } - val groups = ((state as? State.Data)?.data as? ConfigConstraintsState.Loaded) ?.groups .orEmpty() @@ -228,9 +178,10 @@ private fun ConstraintsScreen( modifier = Modifier.weight(1f), state = data, onAddToGroupClick = onAddToGroupClick, - onRemoveClick = { constraintToDelete = it }, - onRemoveGroupClick = { groupToDelete = it }, + onRemoveClick = onRemoveClick, + onRemoveGroupClick = onRemoveGroupClick, onNotClick = onNotClick, + onEditClick = onEditClick, onFixErrorClick = onFixErrorClick, onClickShortcut = onClickShortcut, onSelectGroupMode = onSelectGroupMode, @@ -294,6 +245,7 @@ private fun ConstraintGroupList( onRemoveClick: (String) -> Unit, onRemoveGroupClick: (String) -> Unit, onNotClick: (String) -> Unit, + onEditClick: (String) -> Unit, onFixErrorClick: (String) -> Unit, onClickShortcut: (ConstraintData) -> Unit, onSelectGroupMode: (String, ConstraintMode) -> Unit, @@ -362,6 +314,7 @@ private fun ConstraintGroupList( onRemoveConstraintClick = onRemoveClick, onFixConstraintClick = onFixErrorClick, onNotClick = onNotClick, + onEditConstraintClick = onEditClick, onMoveUp = if (index > 0) { { onMoveGroup(index, index - 1) } } else { @@ -472,12 +425,14 @@ private fun LoadedPreview() { groups = listOf( ConstraintGroupListItemModel( uid = "group1", + icon = flashlightConstraint.icon, mode = ConstraintMode.AND, constraints = listOf(flashlightConstraint, appConstraint), description = "Flashlight is not on AND Key Mapper is in foreground", ), ConstraintGroupListItemModel( uid = "group2", + icon = appConstraint.icon, name = "Foreground or wifi", mode = ConstraintMode.OR, constraints = listOf(appConstraint, wifiConstraint), @@ -485,6 +440,7 @@ private fun LoadedPreview() { ), ConstraintGroupListItemModel( uid = "group3", + icon = wifiConstraint.icon, mode = ConstraintMode.AND, constraints = listOf(wifiConstraint), description = "Wi-Fi is on", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintDelegate.kt new file mode 100644 index 0000000000..df5f64ca5d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintDelegate.kt @@ -0,0 +1,419 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.DialogModel +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.variables.VariablesUseCase +import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.common.utils.PhysicalOrientation +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.system.volume.RingerMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.serialization.json.Json + +/** + * Holds all of the logic to create or edit a [ConstraintData], analogous to + * [io.github.sds100.keymapper.base.actions.CreateActionDelegate] for actions. + */ +class CreateConstraintDelegate( + private val useCase: CreateConstraintUseCase, + dialogProvider: DialogProvider, + navigationProvider: NavigationProvider, + resourceProvider: ResourceProvider, + variablesUseCase: VariablesUseCase, +) : ResourceProvider by resourceProvider, + DialogProvider by dialogProvider, + NavigationProvider by navigationProvider { + + val constraintResult: MutableStateFlow = MutableStateFlow(null) + + var timeConstraintState: ConstraintData.Time? by mutableStateOf(null) + + var displayResolutionState: DisplayResolutionSheetState? by mutableStateOf(null) + private set + + val variableDelegate = VariableConstraintDelegate(variablesUseCase) { constraint -> + constraintResult.update { constraint } + } + + fun onSelectDisplayResolution(resolution: SizeKM) { + displayResolutionState = displayResolutionState?.copy( + isCustom = false, + selectedResolution = resolution, + ) + } + + fun onSelectCustomDisplayResolution() { + displayResolutionState = displayResolutionState?.copy(isCustom = true) + } + + fun onDisplayResolutionWidthChange(width: String) { + displayResolutionState = displayResolutionState?.copy( + widthText = width.filter(Char::isDigit), + ) + } + + fun onDisplayResolutionHeightChange(height: String) { + displayResolutionState = displayResolutionState?.copy( + heightText = height.filter(Char::isDigit), + ) + } + + fun onDismissDisplayResolution() { + displayResolutionState = null + } + + fun onDoneConfigDisplayResolutionClick() { + val state = displayResolutionState ?: return + val resolution = state.resolvedResolution ?: return + + displayResolutionState = null + constraintResult.update { + ConstraintData.DisplayResolution(width = resolution.width, height = resolution.height) + } + } + + fun onDoneConfigTimeConstraintClick() { + timeConstraintState?.let { constraintData -> + timeConstraintState = null + constraintResult.update { constraintData } + } + } + + /** + * @return the newly created [ConstraintData], or null if the user cancelled. + */ + suspend fun createConstraint(id: ConstraintId) { + configConstraint(id)?.let { data -> constraintResult.update { data } } + } + + /** + * @param oldData the constraint being edited. Its [ConstraintId] determines which picker is + * shown, pre-filled with the values from [oldData]. + */ + suspend fun editConstraint(oldData: ConstraintData) { + if (!oldData.isEditable()) { + throw IllegalArgumentException( + "This constraint ${oldData.javaClass.name} can't be edited!", + ) + } + + configConstraint(oldData.id, oldData)?.let { data -> constraintResult.update { data } } + } + + private suspend fun configConstraint( + constraintId: ConstraintId, + oldData: ConstraintData? = null, + ): ConstraintData? { + when (constraintId) { + ConstraintId.APP_IN_FOREGROUND, + ConstraintId.APP_PLAYING_MEDIA, + -> return onSelectAppConstraint(constraintId) + + ConstraintId.MEDIA_PLAYING -> return ConstraintData.MediaPlaying + + ConstraintId.NOTIFICATION_POSTED -> + return onSelectNotificationConstraint(oldData as? ConstraintData.NotificationPosted) + + ConstraintId.BT_DEVICE_CONNECTED -> return onSelectBluetoothConstraint() + + ConstraintId.SCREEN_ON -> return ConstraintData.ScreenOn + + ConstraintId.DISPLAY_RESOLUTION -> { + val oldResolution = oldData as? ConstraintData.DisplayResolution + displayResolutionState = buildDisplayResolutionState(oldResolution) + return null + } + + ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> return ConstraintData.OrientationPortrait + + ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE -> return ConstraintData.OrientationLandscape + + ConstraintId.DISPLAY_ORIENTATION_0 -> + return ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_0) + + ConstraintId.DISPLAY_ORIENTATION_90 -> + return ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_90) + + ConstraintId.DISPLAY_ORIENTATION_180 -> + return ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_180) + + ConstraintId.DISPLAY_ORIENTATION_270 -> + return ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_270) + + ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT -> + return ConstraintData.PhysicalOrientation( + physicalOrientation = PhysicalOrientation.PORTRAIT, + ) + + ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE -> + return ConstraintData.PhysicalOrientation( + physicalOrientation = PhysicalOrientation.LANDSCAPE, + ) + + ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED -> + return ConstraintData.PhysicalOrientation( + physicalOrientation = PhysicalOrientation.PORTRAIT_INVERTED, + ) + + ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED -> + return ConstraintData.PhysicalOrientation( + physicalOrientation = PhysicalOrientation.LANDSCAPE_INVERTED, + ) + + ConstraintId.FLASHLIGHT_ON -> { + val lens = chooseFlashlightLens() ?: return null + return ConstraintData.FlashlightOn(lens = lens) + } + + ConstraintId.WIFI_ON -> return ConstraintData.WifiOn + + ConstraintId.WIFI_CONNECTED -> + return onSelectWifiConnectedConstraint(oldData as? ConstraintData.WifiConnected) + + ConstraintId.IME_CHOSEN -> return onSelectImeChosenConstraint() + + ConstraintId.KEYBOARD_SHOWING -> return ConstraintData.KeyboardShowing + + ConstraintId.DEVICE_IS_LOCKED -> return ConstraintData.DeviceIsLocked + + ConstraintId.IN_PHONE_CALL -> return ConstraintData.InPhoneCall + + ConstraintId.NOT_IN_PHONE_CALL -> return ConstraintData.NotInPhoneCall + + ConstraintId.PHONE_RINGING -> return ConstraintData.PhoneRinging + + ConstraintId.RINGER_MODE_NORMAL -> return ConstraintData.RingerMode(RingerMode.NORMAL) + + ConstraintId.RINGER_MODE_VIBRATE -> return ConstraintData.RingerMode(RingerMode.VIBRATE) + + ConstraintId.RINGER_MODE_SILENT -> return ConstraintData.RingerMode(RingerMode.SILENT) + + ConstraintId.CHARGING -> return ConstraintData.Charging + + ConstraintId.HINGE_CLOSED -> return ConstraintData.HingeClosed + + ConstraintId.HINGE_OPEN -> return ConstraintData.HingeOpen + + ConstraintId.LOCK_SCREEN_SHOWING -> return ConstraintData.LockScreenShowing + + ConstraintId.NOTIFICATION_PANEL_SHOWING -> + return ConstraintData.NotificationPanelShowing + + ConstraintId.TIME -> { + timeConstraintState = oldData as? ConstraintData.Time ?: ConstraintData.Time( + startHour = 0, + startMinute = 0, + endHour = 0, + endMinute = 0, + ) + return null + } + + ConstraintId.VARIABLE -> { + variableDelegate.open(oldData as? ConstraintData.Variable) + return null + } + } + } + + private fun buildDisplayResolutionState( + oldResolution: ConstraintData.DisplayResolution?, + ): DisplayResolutionSheetState { + val supportedResolutions = useCase.getSupportedResolutions() + + val currentResolution = if (oldResolution != null) { + SizeKM(width = oldResolution.width, height = oldResolution.height) + } else { + useCase.getCurrentResolution() + } + + val matchingResolution = supportedResolutions.firstOrNull { + it.matchesIgnoringOrientation(currentResolution) + } + + return DisplayResolutionSheetState( + supportedResolutions = supportedResolutions.sortedBy { it.width }, + // Show the text fields immediately when there is nothing meaningful to pick + // from or when the current resolution isn't one of the supported modes. + isCustom = supportedResolutions.size <= 1 || matchingResolution == null, + selectedResolution = matchingResolution ?: supportedResolutions.firstOrNull(), + widthText = currentResolution.width.toString(), + heightText = currentResolution.height.toString(), + ) + } + + private suspend fun chooseFlashlightLens(): CameraLens? { + val items = useCase.getFlashlightLenses().map { lens -> + val label = when (lens) { + CameraLens.FRONT -> R.string.lens_front + CameraLens.BACK -> R.string.lens_back + } + lens to getString(label) + } + + if (items.size == 1) { + return items.first().first + } + + val dialog = DialogModel.SingleChoice(items) + + return showDialog("choose_flashlight_lens", dialog) + } + + private suspend fun onSelectWifiConnectedConstraint( + oldData: ConstraintData.WifiConnected?, + ): ConstraintData.WifiConnected? { + val knownSSIDs: List = useCase.getKnownWiFiSSIDs() + + val chosenSSID: String? + + val savedWifiSSIDs: List = useCase.getSavedWifiSSIDs().first() + + val ssidEntries = buildList { + addAll(savedWifiSSIDs) + addAll(knownSSIDs) + }.distinct() + + val dialog = DialogModel.Text( + hint = getString(R.string.hint_wifi_ssid), + allowEmpty = true, + message = getString(R.string.constraint_wifi_message_cant_list_networks), + autoCompleteEntries = ssidEntries, + text = oldData?.ssid.orEmpty(), + ) + + val ssidText = showDialog("type_ssid", dialog) ?: return null + + if (ssidText.isBlank()) { + chosenSSID = null + } else { + chosenSSID = ssidText + useCase.saveWifiSSID(chosenSSID) + } + + return ConstraintData.WifiConnected(ssid = chosenSSID) + } + + private suspend fun onSelectImeChosenConstraint(): ConstraintData.ImeChosen? { + val inputMethods = useCase.getEnabledInputMethods() + val items = inputMethods.map { it.id to it.label } + val dialog = DialogModel.SingleChoice(items = items) + + val result = showDialog("choose_input_method", dialog) ?: return null + + val imeInfo = inputMethods.single { it.id == result } + + return ConstraintData.ImeChosen(imeId = imeInfo.id, imeLabel = imeInfo.label) + } + + private suspend fun onSelectBluetoothConstraint(): ConstraintData.BtDeviceConnected? { + val response = showDialog( + "bluetooth_device_constraint_limitation", + DialogModel.Ok(getString(R.string.dialog_message_bt_constraint_limitation)), + ) + + response ?: return null + + val device = navigate( + "choose_bluetooth_device_for_constraint", + NavDestination.ChooseBluetoothDevice, + ) ?: return null + + return ConstraintData.BtDeviceConnected( + bluetoothAddress = device.address, + deviceName = device.name, + ) + } + + private suspend fun onSelectNotificationConstraint( + oldData: ConstraintData.NotificationPosted?, + ): ConstraintData.NotificationPosted? = navigate( + "config_notification_constraint", + NavDestination.ConfigNotificationConstraint(oldData?.let { Json.encodeToString(it) }), + ) + + private suspend fun onSelectAppConstraint(type: ConstraintId): ConstraintData? { + val result = + navigate( + "choose_package_for_constraint", + NavDestination.ChooseApp(allowHiddenApps = true), + ) + ?: return null + + return when (type) { + ConstraintId.APP_IN_FOREGROUND -> ConstraintData.AppInForeground( + packageName = result.packageName, + appName = result.appName, + ) + + ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( + packageName = result.packageName, + appName = result.appName, + ) + + else -> throw IllegalArgumentException( + "Don't know how to create $type constraint after choosing app", + ) + } + } +} + +/** + * State for the display resolution bottom sheet. + * + * @param supportedResolutions the resolutions the display reports as supported. + * @param isCustom whether the user is entering a custom resolution instead of picking a chip. + * @param selectedResolution the currently selected supported resolution, if any. + * @param widthText the custom width input. + * @param heightText the custom height input. + */ +data class DisplayResolutionSheetState( + val supportedResolutions: List, + val isCustom: Boolean, + val selectedResolution: SizeKM?, + val widthText: String, + val heightText: String, +) { + private val customWidth: Int? get() = widthText.toIntOrNull() + private val customHeight: Int? get() = heightText.toIntOrNull() + + /** + * The resolution that will be saved, or null when the current input is not valid. + */ + val resolvedResolution: SizeKM? + get() = if (isCustom) { + val width = customWidth + val height = customHeight + + if (width != null && width > 0 && height != null && height > 0) { + SizeKM(width, height) + } else { + null + } + } else { + selectedResolution + } + + val isValid: Boolean get() = resolvedResolution != null +} + +/** + * Compares two resolutions ignoring orientation so that e.g. 1080x1920 and 1920x1080 + * are treated as the same resolution. + */ +private fun SizeKM.matchesIgnoringOrientation(other: SizeKM): Boolean { + return (width == other.width && height == other.height) || + (width == other.height && height == other.width) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt index dfff01cde4..c026b870a6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt @@ -1,8 +1,6 @@ package io.github.sds100.keymapper.base.constraints -import android.content.pm.PackageManager -import android.os.Build -import io.github.sds100.keymapper.common.utils.KMError +import android.view.Display import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository @@ -12,6 +10,7 @@ import io.github.sds100.keymapper.system.display.DisplayAdapter import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter +import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -23,29 +22,12 @@ class CreateConstraintUseCaseImpl @Inject constructor( private val preferenceRepository: PreferenceRepository, private val cameraAdapter: CameraAdapter, private val displayAdapter: DisplayAdapter, -) : CreateConstraintUseCase { - - override fun isSupported(constraint: ConstraintId): KMError? { - when (constraint) { - ConstraintId.FLASHLIGHT_ON -> { - if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && - cameraAdapter.getFlashInfo(CameraLens.FRONT) == null - ) { - return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH) - } - } - - ConstraintId.HINGE_CLOSED, ConstraintId.HINGE_OPEN -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - return KMError.SdkVersionTooLow(Build.VERSION_CODES.R) - } - } - - else -> Unit - } - - return null - } + systemFeatureAdapter: SystemFeatureAdapter, +) : CreateConstraintUseCase, + IsConstraintSupportedUseCase by IsConstraintSupportedUseCaseImpl( + systemFeatureAdapter, + cameraAdapter, + ) { override fun getKnownWiFiSSIDs(): List = networkAdapter.getKnownWifiSSIDs() @@ -80,13 +62,17 @@ class CreateConstraintUseCaseImpl @Inject constructor( return CameraLens.entries.filter { cameraAdapter.getFlashInfo(it) != null }.toSet() } - override fun getSupportedResolutions(): Set = displayAdapter.supportedResolutions.value + override fun getSupportedResolutions(): Set = + displayAdapter.displays.value.flatMap { it.supportedSizes }.toSet() - override fun getCurrentResolution(): SizeKM = displayAdapter.size + override fun getCurrentResolution(): SizeKM { + return displayAdapter.getDisplay( + displayAdapter.activityDisplayId ?: Display.DEFAULT_DISPLAY, + )!!.activeSize + } } -interface CreateConstraintUseCase { - fun isSupported(constraint: ConstraintId): KMError? +interface CreateConstraintUseCase : IsConstraintSupportedUseCase { fun getKnownWiFiSSIDs(): List fun getEnabledInputMethods(): List diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt index bb722e7ee7..e395982aee 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt @@ -5,6 +5,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService +import io.github.sds100.keymapper.base.variables.VariablesUseCase import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.devices.DevicesAdapter @@ -38,6 +39,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( private val foldableAdapter: FoldableAdapter, private val volumeAdapter: VolumeAdapter, private val notificationAdapter: NotificationAdapter, + private val variablesUseCase: VariablesUseCase, ) : DetectConstraintsUseCase { @AssistedFactory @@ -59,6 +61,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( foldableAdapter, volumeAdapter, notificationAdapter, + variablesUseCase, ) override fun onDependencyChanged(dependency: ConstraintDependency): Flow { @@ -79,7 +82,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( ConstraintDependency.SCREEN_STATE -> displayAdapter.isScreenOn.map { dependency } ConstraintDependency.DISPLAY_ORIENTATION -> - displayAdapter.orientation.map { dependency } + displayAdapter.displays.map { dependency } ConstraintDependency.PHYSICAL_ORIENTATION -> displayAdapter.physicalOrientation.map { dependency } @@ -127,9 +130,11 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( ConstraintDependency.POSTED_NOTIFICATIONS -> notificationAdapter.activeNotifications.map { dependency } - ConstraintDependency.DISPLAY_RESOLUTIONS -> displayAdapter.supportedResolutions.map { + ConstraintDependency.DISPLAY_RESOLUTIONS -> displayAdapter.displays.map { dependency } + + ConstraintDependency.VARIABLES -> variablesUseCase.values.map { dependency } } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DisplayResolutionConstraintBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DisplayResolutionConstraintBottomSheet.kt index b8795342d3..4cff0af79e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DisplayResolutionConstraintBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DisplayResolutionConstraintBottomSheet.kt @@ -1,21 +1,15 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue @@ -27,29 +21,29 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DisplayResolutionConstraintBottomSheet(viewModel: ChooseConstraintViewModel) { +fun DisplayResolutionConstraintBottomSheet(delegate: CreateConstraintDelegate) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val state = viewModel.displayResolutionState ?: return + val state = delegate.displayResolutionState ?: return DisplayResolutionConstraintBottomSheet( sheetState = sheetState, state = state, - onSelectResolution = viewModel::onSelectDisplayResolution, - onSelectCustom = viewModel::onSelectCustomDisplayResolution, - onWidthChange = viewModel::onDisplayResolutionWidthChange, - onHeightChange = viewModel::onDisplayResolutionHeightChange, - onDismissRequest = viewModel::onDismissDisplayResolution, - onDoneClick = viewModel::onDoneConfigDisplayResolutionClick, + onSelectResolution = delegate::onSelectDisplayResolution, + onSelectCustom = delegate::onSelectCustomDisplayResolution, + onWidthChange = delegate::onDisplayResolutionWidthChange, + onHeightChange = delegate::onDisplayResolutionHeightChange, + onDismissRequest = delegate::onDismissDisplayResolution, + onDoneClick = delegate::onDoneConfigDisplayResolutionClick, ) } @@ -67,127 +61,80 @@ private fun DisplayResolutionConstraintBottomSheet( ) { val scope = rememberCoroutineScope() - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = stringResource(R.string.constraint_display_resolution_bottom_sheet_title), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + scope.launch { + sheetState.hide() + onDoneClick() + } + }, + posButtonEnabled = state.isValid, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.constraint_display_resolution_bottom_sheet_title), - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // The chips are only useful when there is more than one supported mode to pick from. - if (state.supportedResolutions.size > 1) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (resolution in state.supportedResolutions) { - FilterChip( - selected = !state.isCustom && state.selectedResolution == resolution, - onClick = { onSelectResolution(resolution) }, - label = { Text("${resolution.width} × ${resolution.height}") }, - ) - } - + // The chips are only useful when there is more than one supported mode to pick from. + if (state.supportedResolutions.size > 1) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (resolution in state.supportedResolutions) { FilterChip( - selected = state.isCustom, - onClick = onSelectCustom, - label = { - Text( - stringResource( - R.string.constraint_display_resolution_custom_chip, - ), - ) - }, + selected = !state.isCustom && state.selectedResolution == resolution, + onClick = { onSelectResolution(resolution) }, + label = { Text("${resolution.width} × ${resolution.height}") }, ) } - Spacer(modifier = Modifier.height(16.dp)) + FilterChip( + selected = state.isCustom, + onClick = onSelectCustom, + label = { + Text( + stringResource( + R.string.constraint_display_resolution_custom_chip, + ), + ) + }, + ) } - if (state.isCustom) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlinedTextField( - modifier = Modifier.weight(1f), - value = state.widthText, - onValueChange = onWidthChange, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { - Text(stringResource(R.string.constraint_display_resolution_width)) - }, - ) - - OutlinedTextField( - modifier = Modifier.weight(1f), - value = state.heightText, - onValueChange = onHeightChange, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - label = { - Text(stringResource(R.string.constraint_display_resolution_height)) - }, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - } + Spacer(modifier = Modifier.height(16.dp)) + } + if (state.isCustom) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - OutlinedButton( + OutlinedTextField( modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } + value = state.widthText, + onValueChange = onWidthChange, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { + Text(stringResource(R.string.constraint_display_resolution_width)) }, - ) { - Text(stringResource(R.string.neg_cancel)) - } + ) - Spacer(modifier = Modifier.width(16.dp)) - - Button( + OutlinedTextField( modifier = Modifier.weight(1f), - enabled = state.isValid, - onClick = { - scope.launch { - sheetState.hide() - onDoneClick() - } + value = state.heightText, + onValueChange = onHeightChange, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { + Text(stringResource(R.string.constraint_display_resolution_height)) }, - ) { - Text(stringResource(R.string.pos_done)) - } + ) } - Spacer(Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/IsConstraintSupportedUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/IsConstraintSupportedUseCase.kt new file mode 100644 index 0000000000..1b6b92f2a2 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/IsConstraintSupportedUseCase.kt @@ -0,0 +1,57 @@ +package io.github.sds100.keymapper.base.constraints + +import android.content.pm.PackageManager +import android.os.Build +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.system.camera.CameraAdapter +import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter + +class IsConstraintSupportedUseCaseImpl( + private val systemFeatureAdapter: SystemFeatureAdapter, + private val cameraAdapter: CameraAdapter, +) : IsConstraintSupportedUseCase { + + override fun isSupported(id: ConstraintId): KMError? { + when (id) { + ConstraintId.BT_DEVICE_CONNECTED -> { + if (!systemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { + return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) + } + } + + ConstraintId.FLASHLIGHT_ON -> { + if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && + cameraAdapter.getFlashInfo(CameraLens.FRONT) == null + ) { + return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH) + } + } + + ConstraintId.HINGE_CLOSED, + ConstraintId.HINGE_OPEN, + -> { + if (Build.VERSION.SDK_INT != 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return KMError.SdkVersionTooLow(Build.VERSION_CODES.R) + } + + if (!systemFeatureAdapter.hasSystemFeature( + PackageManager.FEATURE_SENSOR_HINGE_ANGLE, + ) + ) { + return KMError.SystemFeatureNotSupported( + PackageManager.FEATURE_SENSOR_HINGE_ANGLE, + ) + } + } + + else -> Unit + } + + return null + } +} + +interface IsConstraintSupportedUseCase { + fun isSupported(id: ConstraintId): KMError? +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt index e17627be2f..c677b458ff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt @@ -1,25 +1,16 @@ package io.github.sds100.keymapper.base.constraints -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.fillMaxWidth -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.Edit import androidx.compose.material.icons.rounded.Timer import androidx.compose.material.icons.rounded.TimerOff import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text @@ -37,11 +28,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.common.utils.TimeUtils import java.time.format.FormatStyle @@ -49,25 +40,25 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TimeConstraintBottomSheet(viewModel: ChooseConstraintViewModel) { +fun TimeConstraintBottomSheet(delegate: CreateConstraintDelegate) { val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (viewModel.timeConstraintState != null) { + if (delegate.timeConstraintState != null) { TimeConstraintBottomSheet( sheetState = sheetState, onDismissRequest = { - viewModel.timeConstraintState = null + delegate.timeConstraintState = null }, - state = viewModel.timeConstraintState!!, + state = delegate.timeConstraintState!!, onSelectStartTime = { hour, min -> - viewModel.timeConstraintState = viewModel.timeConstraintState?.copy( + delegate.timeConstraintState = delegate.timeConstraintState?.copy( startHour = hour, startMinute = min, ) }, onSelectEndTime = { hour, min -> - viewModel.timeConstraintState = viewModel.timeConstraintState?.copy( + delegate.timeConstraintState = delegate.timeConstraintState?.copy( endHour = hour, endMinute = min, ) @@ -75,7 +66,7 @@ fun TimeConstraintBottomSheet(viewModel: ChooseConstraintViewModel) { onDoneClick = { scope.launch { sheetState.hide() - viewModel.onDoneConfigTimeConstraintClick() + delegate.onDoneConfigTimeConstraintClick() } }, ) @@ -92,7 +83,6 @@ private fun TimeConstraintBottomSheet( onSelectEndTime: (Int, Int) -> Unit = { _, _ -> }, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() val formatter = remember { TimeUtils.localeDateFormatter(FormatStyle.SHORT) } val startTimePickerState = rememberTimePickerState() @@ -127,121 +117,74 @@ private fun TimeConstraintBottomSheet( ) } - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = stringResource(R.string.constraint_time_bottom_sheet_title), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column { - Spacer(modifier = Modifier.height(16.dp)) + OptionsHeaderRow( + icon = Icons.Rounded.Timer, + text = stringResource(R.string.constraint_time_bottom_sheet_start_time), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.constraint_time_bottom_sheet_title), - style = MaterialTheme.typography.headlineMedium, + text = formatter.format(state.startTime), + style = MaterialTheme.typography.titleLarge, ) - Spacer(modifier = Modifier.height(8.dp)) + IconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = { + startTimePickerState.hour = state.startHour + startTimePickerState.minute = state.startMinute - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.Timer, - text = stringResource(R.string.constraint_time_bottom_sheet_start_time), - ) - - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, + showStartTimePickerDialog = true + }, ) { - Text( - text = formatter.format(state.startTime), - style = MaterialTheme.typography.titleLarge, + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource( + R.string.constraint_time_bottom_sheet_edit_start_time, + ), ) - - IconButton( - modifier = Modifier.padding(start = 8.dp), - onClick = { - startTimePickerState.hour = state.startHour - startTimePickerState.minute = state.startMinute - - showStartTimePickerDialog = true - }, - ) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource( - R.string.constraint_time_bottom_sheet_edit_start_time, - ), - ) - } } + } - OptionsHeaderRow( - modifier = Modifier.padding(horizontal = 16.dp), - icon = Icons.Rounded.TimerOff, - text = stringResource(R.string.constraint_time_bottom_sheet_end_time), - ) - - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatter.format(state.endTime), - style = MaterialTheme.typography.titleLarge, - ) + OptionsHeaderRow( + icon = Icons.Rounded.TimerOff, + text = stringResource(R.string.constraint_time_bottom_sheet_end_time), + ) - IconButton( - modifier = Modifier.padding(start = 8.dp), - onClick = { - endTimePickerState.hour = state.endHour - endTimePickerState.minute = state.endMinute + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatter.format(state.endTime), + style = MaterialTheme.typography.titleLarge, + ) - showEndTimePickerDialog = true - }, - ) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource( - R.string.constraint_time_bottom_sheet_edit_end_time, - ), - ) - } - } + IconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = { + endTimePickerState.hour = state.endHour + endTimePickerState.minute = state.endMinute - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + showEndTimePickerDialog = true + }, ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource( + R.string.constraint_time_bottom_sheet_edit_end_time, + ), + ) } - - Spacer(Modifier.height(16.dp)) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/VariableConstraintBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/VariableConstraintBottomSheet.kt new file mode 100644 index 0000000000..726aa1db45 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/VariableConstraintBottomSheet.kt @@ -0,0 +1,187 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices.TABLET +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet +import io.github.sds100.keymapper.base.utils.ui.compose.icons.Equal +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.variables.VariableComparison +import io.github.sds100.keymapper.base.variables.VariableConfigContent +import io.github.sds100.keymapper.base.variables.VariableSuggestion +import kotlinx.coroutines.launch + +data class VariableConstraintSheetState( + val name: String = "", + val comparison: VariableComparison = VariableComparison.EQUALS, + val value: String = "0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VariableConstraintBottomSheet(delegate: VariableConstraintDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val state = delegate.state + val suggestions by delegate.suggestions.collectAsState(emptyList()) + + if (state != null) { + VariableConstraintBottomSheet( + sheetState = sheetState, + state = state, + suggestions = suggestions, + onDismissRequest = delegate::onDismiss, + onNameChange = delegate::onNameChange, + onValueChange = delegate::onValueChange, + onCycleComparisonClick = delegate::onCycleComparisonClick, + onResetClick = delegate::onResetClick, + onResetAllClick = delegate::onResetAllClick, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VariableConstraintBottomSheet( + sheetState: SheetState, + state: VariableConstraintSheetState, + suggestions: List = emptyList(), + onDismissRequest: () -> Unit = {}, + onNameChange: (String) -> Unit = {}, + onValueChange: (String) -> Unit = {}, + onCycleComparisonClick: () -> Unit = {}, + onResetClick: (String) -> Unit = {}, + onResetAllClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val nameEmptyError = stringResource(R.string.variable_name_error) + var nameError: String? by rememberSaveable { mutableStateOf(null) } + + if (state.name.isNotBlank() && nameError != null) { + nameError = null + } + + KMBottomSheet( + title = stringResource(R.string.constraint_variable), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + if (state.name.isBlank()) { + nameError = nameEmptyError + } else { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + VariableConfigContent( + modifier = Modifier.fillMaxWidth(), + name = state.name, + onNameChange = onNameChange, + value = state.value, + onValueChange = onValueChange, + suggestions = suggestions, + onSuggestionClick = onNameChange, + onResetClick = onResetClick, + onResetAllClick = onResetAllClick, + nameError = nameError, + onCycleOperationClick = onCycleComparisonClick, + operationLabel = getComparisonLabel(state.comparison), + operationIcon = { + Icon( + imageVector = getComparisonIcon(state.comparison), + contentDescription = getComparisonLabel(state.comparison), + ) + }, + ) + } +} + +private fun getComparisonIcon(comparison: VariableComparison): ImageVector { + return when (comparison) { + VariableComparison.EQUALS -> KeyMapperIcons.Equal + VariableComparison.GREATER_THAN -> Icons.Rounded.ChevronRight + VariableComparison.LESS_THAN -> Icons.Rounded.ChevronLeft + } +} + +@Composable +private fun getComparisonLabel(comparison: VariableComparison): String { + return when (comparison) { + VariableComparison.EQUALS -> stringResource(R.string.variable_comparison_equals) + VariableComparison.GREATER_THAN -> stringResource(R.string.variable_comparison_greater_than) + VariableComparison.LESS_THAN -> stringResource(R.string.variable_comparison_less_than) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true, device = TABLET) +@Composable +private fun VariableConstraintBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + VariableConstraintBottomSheet( + sheetState = sheetState, + state = VariableConstraintSheetState( + name = "counter", + comparison = VariableComparison.GREATER_THAN, + value = "3", + ), + suggestions = listOf( + VariableSuggestion("counter", 3), + VariableSuggestion("mode", 1), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun VariableConstraintBottomSheetEmptyPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + VariableConstraintBottomSheet( + sheetState = sheetState, + state = VariableConstraintSheetState(), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/VariableConstraintDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/VariableConstraintDelegate.kt new file mode 100644 index 0000000000..1689f00203 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/VariableConstraintDelegate.kt @@ -0,0 +1,61 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.github.sds100.keymapper.base.variables.VariableComparison +import io.github.sds100.keymapper.base.variables.VariablesUseCase +import io.github.sds100.keymapper.base.variables.filterVariableValue + +class VariableConstraintDelegate( + private val variablesUseCase: VariablesUseCase, + private val onResult: (ConstraintData.Variable) -> Unit, +) { + var state: VariableConstraintSheetState? by mutableStateOf(null) + private set + + val suggestions = variablesUseCase.suggestions + + fun open(oldConstraint: ConstraintData.Variable?) { + state = VariableConstraintSheetState( + name = oldConstraint?.name ?: "", + comparison = oldConstraint?.comparison ?: VariableComparison.EQUALS, + value = oldConstraint?.value?.toString() ?: "0", + ) + } + + fun onDismiss() { + state = null + } + + fun onNameChange(name: String) { + state = state?.copy(name = name.trim()) + } + + fun onValueChange(value: String) { + state = state?.copy(value = value.filterVariableValue()) + } + + fun onCycleComparisonClick() { + state = state?.let { it.copy(comparison = it.comparison.next()) } + } + + fun onResetClick(name: String) { + variablesUseCase.reset(name) + } + + fun onResetAllClick() { + variablesUseCase.resetAll() + } + + fun onDoneClick() { + val state = state ?: return + val constraint = ConstraintData.Variable( + name = state.name, + comparison = state.comparison, + value = state.value.toLongOrNull() ?: 0L, + ) + onDismiss() + onResult(constraint) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupDelegateImpl.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupDelegateImpl.kt index 2d1c491ad9..2bd82304c5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupDelegateImpl.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupDelegateImpl.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.expertmode import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import javax.inject.Inject import javax.inject.Named import kotlinx.coroutines.CoroutineScope @@ -13,12 +12,10 @@ class ExpertModeSetupDelegateImpl @Inject constructor( @Named("viewmodel") viewModelScope: CoroutineScope, useCase: SystemBridgeSetupUseCase, - resourceProvider: ResourceProvider, private val navigationProvider: NavigationProvider, ) : SystemBridgeSetupDelegateImpl( viewModelScope, useCase, - resourceProvider, ) { override fun onFinishClick() { viewModelScope.launch { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt index 231bbe69f8..5002af3262 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt @@ -26,10 +26,13 @@ import androidx.compose.material.icons.rounded.Lan import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Security import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -47,7 +50,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -59,7 +61,6 @@ import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons import io.github.sds100.keymapper.base.utils.ui.compose.icons.SignalWifiNotConnected import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep @Composable fun ExpertModeSetupScreen(viewModel: ExpertModeSetupViewModel) { @@ -71,6 +72,7 @@ fun ExpertModeSetupScreen(viewModel: ExpertModeSetupViewModel) { onAssistantClick = viewModel::onSetupAssistantClick, onWatchTutorialClick = { }, onBackClick = viewModel::onBackClick, + onSamsungAutoBlockerWarningClick = viewModel::onSamsungAutoBlockerWarningClick, ) } @@ -82,6 +84,7 @@ fun ExpertModeSetupScreen( onStepButtonClick: () -> Unit = {}, onAssistantClick: () -> Unit = {}, onWatchTutorialClick: () -> Unit = {}, + onSamsungAutoBlockerWarningClick: () -> Unit = {}, ) { Scaffold( topBar = { @@ -106,6 +109,7 @@ fun ExpertModeSetupScreen( onAssistantClick, onWatchTutorialClick, onStepButtonClick, + onSamsungAutoBlockerWarningClick, ) } } @@ -117,6 +121,7 @@ fun ExpertModeSetupScreenContent( onAssistantClick: () -> Unit, onWatchTutorialClick: () -> Unit, onStepButtonClick: () -> Unit, + onSamsungAutoBlockerWarningClick: () -> Unit = {}, ) { when (state) { State.Loading -> { @@ -129,11 +134,12 @@ fun ExpertModeSetupScreenContent( } is State.Data -> { - val stepContent = state.data.stepContent + val stepData = state.data + val stepContent = getStepContent(stepData) // Create animated progress for entrance and updates val progressAnimatable = remember { Animatable(0f) } - val targetProgress = state.data.stepNumber.toFloat() / (state.data.stepCount) + val targetProgress = stepData.stepNumber.toFloat() / (stepData.stepCount) // Animate progress when it changes LaunchedEffect(targetProgress) { @@ -177,8 +183,8 @@ fun ExpertModeSetupScreenContent( Text( text = stringResource( R.string.expert_mode_setup_wizard_step_n, - state.data.stepNumber, - state.data.stepCount, + stepData.stepNumber, + stepData.stepCount, ), style = MaterialTheme.typography.titleLarge, ) @@ -191,17 +197,24 @@ fun ExpertModeSetupScreenContent( AssistantCheckBoxRow( modifier = Modifier.fillMaxWidth(), - isEnabled = state.data.isSetupAssistantButtonEnabled, - isChecked = state.data.isSetupAssistantChecked, + isEnabled = stepData.isSetupAssistantButtonEnabled, + isChecked = stepData.isSetupAssistantChecked, onAssistantClick = onAssistantClick, ) - val iconTint = if (state.data.step == SystemBridgeSetupStep.STARTED) { + val iconTint = if (stepData is ExpertModeSetupState.Started) { LocalCustomColorsPalette.current.green } else { MaterialTheme.colorScheme.onSurface } + val isStarting = stepData is ExpertModeSetupState.StartService && + stepData.isStarting + + val showSamsungAutoBlockerWarning = + stepData is ExpertModeSetupState.WirelessDebugging && + stepData.showSamsungAutoBlockerWarning + StepContent( modifier = Modifier .fillMaxWidth() @@ -211,7 +224,16 @@ fun ExpertModeSetupScreenContent( onWatchTutorialClick = onWatchTutorialClick, onButtonClick = onStepButtonClick, iconTint = iconTint, - isLoading = state.data.isStarting, + isLoading = isStarting, + warningContent = if (showSamsungAutoBlockerWarning) { + { + SamsungAutoBlockerWarningCard( + onClick = onSamsungAutoBlockerWarningClick, + ) + } + } else { + null + }, ) } } @@ -226,6 +248,7 @@ private fun StepContent( onButtonClick: () -> Unit, iconTint: Color = Color.Unspecified, isLoading: Boolean = false, + warningContent: (@Composable () -> Unit)? = null, ) { Column( modifier, @@ -266,6 +289,11 @@ private fun StepContent( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) + + if (warningContent != null) { + Spacer(modifier = Modifier.height(16.dp)) + warningContent() + } } Spacer(Modifier.height(32.dp)) @@ -288,6 +316,59 @@ private fun StepContent( } } +@Composable +private fun SamsungAutoBlockerWarningCard(modifier: Modifier = Modifier, onClick: () -> Unit) { + SetupWarningCard( + modifier = modifier, + title = stringResource( + R.string.expert_mode_setup_wizard_samsung_auto_blocker_warning_title, + ), + description = stringResource( + R.string.expert_mode_setup_wizard_samsung_auto_blocker_warning_description, + ), + buttonText = stringResource(R.string.expert_mode_setup_wizard_go_to_settings_button), + onButtonClick = onClick, + ) +} + +@Composable +private fun SetupWarningCard( + modifier: Modifier = Modifier, + title: String, + description: String, + buttonText: String, + onButtonClick: () -> Unit, +) { + ElevatedCard(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + FilledTonalButton(onClick = onButtonClick) { + Text(buttonText) + } + } + } +} + @Composable private fun AssistantCheckBoxRow( modifier: Modifier, @@ -343,112 +424,107 @@ private fun AssistantCheckBoxRow( } @Composable -private fun getIconForStep(step: SystemBridgeSetupStep): ImageVector { - return when (step) { - SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> Icons.Rounded.Accessibility - SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> Icons.Rounded.Notifications - SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> Icons.Rounded.Lan - SystemBridgeSetupStep.DEVELOPER_OPTIONS -> Icons.Rounded.Build - SystemBridgeSetupStep.WIFI_NETWORK -> KeyMapperIcons.SignalWifiNotConnected - SystemBridgeSetupStep.WIRELESS_DEBUGGING -> Icons.Rounded.BugReport - SystemBridgeSetupStep.ADB_PAIRING -> Icons.Rounded.Link - SystemBridgeSetupStep.START_SERVICE -> Icons.Rounded.PlayArrow - SystemBridgeSetupStep.STARTED -> Icons.Rounded.CheckCircleOutline - } -} - -@Composable -private fun createPreviewStepContent(step: SystemBridgeSetupStep): StepContent { - val icon = getIconForStep(step) - return when (step) { - SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> StepContent( +private fun getStepContent(state: ExpertModeSetupState): StepContent { + return when (state) { + is ExpertModeSetupState.AccessibilityService -> StepContent( title = stringResource( R.string.expert_mode_setup_wizard_enable_accessibility_service_title, ), message = stringResource( R.string.expert_mode_setup_wizard_enable_accessibility_service_description, ), - icon = icon, + icon = Icons.Rounded.Accessibility, buttonText = stringResource( R.string.expert_mode_setup_wizard_enable_accessibility_service_button, ), ) - SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> StepContent( + is ExpertModeSetupState.NotificationPermission -> StepContent( title = stringResource( R.string.expert_mode_setup_wizard_enable_notification_permission_title, ), message = stringResource( R.string.expert_mode_setup_wizard_enable_notification_permission_description, ), - icon = icon, + icon = Icons.Rounded.Notifications, buttonText = stringResource( R.string.expert_mode_setup_wizard_enable_notification_permission_button, ), ) - SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> StepContent( + is ExpertModeSetupState.LocalNetworkPermission -> StepContent( title = stringResource( R.string.expert_mode_setup_wizard_local_network_permission_title, ), message = stringResource( R.string.expert_mode_setup_wizard_local_network_permission_description, ), - icon = icon, + icon = Icons.Rounded.Lan, buttonText = stringResource( R.string.expert_mode_setup_wizard_local_network_permission_button, ), ) - SystemBridgeSetupStep.DEVELOPER_OPTIONS -> StepContent( + is ExpertModeSetupState.SamsungAutoBlocker -> StepContent( + title = stringResource( + R.string.expert_mode_setup_wizard_disable_samsung_auto_blocker_title, + ), + message = stringResource( + R.string.expert_mode_setup_wizard_disable_samsung_auto_blocker_description, + ), + icon = Icons.Rounded.Security, + buttonText = stringResource(R.string.expert_mode_setup_wizard_go_to_settings_button), + ) + + is ExpertModeSetupState.DeveloperOptions -> StepContent( title = stringResource( R.string.expert_mode_setup_wizard_enable_developer_options_title, ), message = stringResource( R.string.expert_mode_setup_wizard_enable_developer_options_description, ), - icon = icon, + icon = Icons.Rounded.Build, buttonText = stringResource(R.string.expert_mode_setup_wizard_go_to_settings_button), ) - SystemBridgeSetupStep.WIFI_NETWORK -> StepContent( + is ExpertModeSetupState.WifiNetwork -> StepContent( title = stringResource(R.string.expert_mode_setup_wizard_connect_wifi_title), message = stringResource(R.string.expert_mode_setup_wizard_connect_wifi_description), - icon = icon, + icon = KeyMapperIcons.SignalWifiNotConnected, buttonText = stringResource(R.string.expert_mode_setup_wizard_go_to_settings_button), ) - SystemBridgeSetupStep.WIRELESS_DEBUGGING -> StepContent( + is ExpertModeSetupState.WirelessDebugging -> StepContent( title = stringResource( R.string.expert_mode_setup_wizard_enable_wireless_debugging_title, ), message = stringResource( R.string.expert_mode_setup_wizard_enable_wireless_debugging_description, ), - icon = icon, + icon = Icons.Rounded.BugReport, buttonText = stringResource(R.string.expert_mode_setup_wizard_go_to_settings_button), ) - SystemBridgeSetupStep.ADB_PAIRING -> StepContent( + is ExpertModeSetupState.AdbPairing -> StepContent( title = stringResource(R.string.expert_mode_setup_wizard_pair_wireless_debugging_title), message = stringResource( R.string.expert_mode_setup_wizard_pair_wireless_debugging_description, ), - icon = icon, + icon = Icons.Rounded.Link, buttonText = stringResource(R.string.expert_mode_setup_wizard_go_to_settings_button), ) - SystemBridgeSetupStep.START_SERVICE -> StepContent( + is ExpertModeSetupState.StartService -> StepContent( title = stringResource(R.string.expert_mode_setup_wizard_start_service_title), message = stringResource(R.string.expert_mode_setup_wizard_start_service_description), - icon = icon, + icon = Icons.Rounded.PlayArrow, buttonText = stringResource(R.string.expert_mode_root_detected_button_start_service), ) - SystemBridgeSetupStep.STARTED -> StepContent( + is ExpertModeSetupState.Started -> StepContent( title = stringResource(R.string.expert_mode_setup_wizard_complete_title), message = stringResource(R.string.expert_mode_setup_wizard_complete_text), - icon = icon, + icon = Icons.Rounded.CheckCircleOutline, buttonText = stringResource(R.string.expert_mode_setup_wizard_complete_button), ) } @@ -458,17 +534,11 @@ private fun createPreviewStepContent(step: SystemBridgeSetupStep): StepContent { @Composable private fun ExpertModeSetupScreenAccessibilityServicePreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.ACCESSIBILITY_SERVICE ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( + ExpertModeSetupState.AccessibilityService( stepNumber = 1, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), - isSetupAssistantChecked = false, - isSetupAssistantButtonEnabled = false, - isStarting = false, + stepCount = 10, ), ), ) @@ -479,17 +549,12 @@ private fun ExpertModeSetupScreenAccessibilityServicePreview() { @Composable private fun ExpertModeSetupScreenNotificationPermissionPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.NOTIFICATION_PERMISSION ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( + ExpertModeSetupState.NotificationPermission( stepNumber = 2, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), + stepCount = 10, isSetupAssistantChecked = false, - isSetupAssistantButtonEnabled = true, - isStarting = false, ), ), ) @@ -500,17 +565,28 @@ private fun ExpertModeSetupScreenNotificationPermissionPreview() { @Composable private fun ExpertModeSetupScreenLocalNetworkPermissionPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( + ExpertModeSetupState.LocalNetworkPermission( stepNumber = 3, - stepCount = 9, - step = step, - stepContent = createPreviewStepContent(step), + stepCount = 10, + isSetupAssistantChecked = false, + ), + ), + ) + } +} + +@Preview(name = "Samsung Auto Blocker Step") +@Composable +private fun ExpertModeSetupScreenSamsungAutoBlockerPreview() { + KeyMapperTheme { + ExpertModeSetupScreen( + state = State.Data( + ExpertModeSetupState.SamsungAutoBlocker( + stepNumber = 4, + stepCount = 10, isSetupAssistantChecked = false, - isSetupAssistantButtonEnabled = true, - isStarting = false, ), ), ) @@ -521,17 +597,12 @@ private fun ExpertModeSetupScreenLocalNetworkPermissionPreview() { @Composable private fun ExpertModeSetupScreenDeveloperOptionsPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.DEVELOPER_OPTIONS ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( - stepNumber = 2, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), + ExpertModeSetupState.DeveloperOptions( + stepNumber = 5, + stepCount = 10, isSetupAssistantChecked = false, - isSetupAssistantButtonEnabled = true, - isStarting = false, ), ), ) @@ -542,17 +613,12 @@ private fun ExpertModeSetupScreenDeveloperOptionsPreview() { @Composable private fun ExpertModeSetupScreenWifiNetworkPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.WIFI_NETWORK ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( - stepNumber = 3, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), + ExpertModeSetupState.WifiNetwork( + stepNumber = 6, + stepCount = 10, isSetupAssistantChecked = false, - isSetupAssistantButtonEnabled = true, - isStarting = false, ), ), ) @@ -563,17 +629,30 @@ private fun ExpertModeSetupScreenWifiNetworkPreview() { @Composable private fun ExpertModeSetupScreenWirelessDebuggingPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.WIRELESS_DEBUGGING ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( - stepNumber = 4, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), + ExpertModeSetupState.WirelessDebugging( + stepNumber = 7, + stepCount = 10, isSetupAssistantChecked = false, - isSetupAssistantButtonEnabled = true, - isStarting = false, + showSamsungAutoBlockerWarning = false, + ), + ), + ) + } +} + +@Preview(name = "Wireless Debugging Step (Auto Blocker Warning)") +@Composable +private fun ExpertModeSetupScreenWirelessDebuggingAutoBlockerWarningPreview() { + KeyMapperTheme { + ExpertModeSetupScreen( + state = State.Data( + ExpertModeSetupState.WirelessDebugging( + stepNumber = 7, + stepCount = 10, + isSetupAssistantChecked = false, + showSamsungAutoBlockerWarning = true, ), ), ) @@ -584,17 +663,12 @@ private fun ExpertModeSetupScreenWirelessDebuggingPreview() { @Composable private fun ExpertModeSetupScreenAdbPairingPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.ADB_PAIRING ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( - stepNumber = 5, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), + ExpertModeSetupState.AdbPairing( + stepNumber = 8, + stepCount = 10, isSetupAssistantChecked = true, - isSetupAssistantButtonEnabled = true, - isStarting = false, ), ), ) @@ -605,16 +679,12 @@ private fun ExpertModeSetupScreenAdbPairingPreview() { @Composable private fun ExpertModeSetupScreenStartServicePreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.START_SERVICE ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( - stepNumber = 6, - stepCount = 6, - step = step, - stepContent = createPreviewStepContent(step), + ExpertModeSetupState.StartService( + stepNumber = 9, + stepCount = 10, isSetupAssistantChecked = true, - isSetupAssistantButtonEnabled = true, isStarting = false, ), ), @@ -626,17 +696,12 @@ private fun ExpertModeSetupScreenStartServicePreview() { @Composable private fun ExpertModeSetupScreenStartedPreview() { KeyMapperTheme { - val step = SystemBridgeSetupStep.STARTED ExpertModeSetupScreen( state = State.Data( - ExpertModeSetupState( - stepNumber = 8, - stepCount = 8, - step = step, - stepContent = createPreviewStepContent(step), + ExpertModeSetupState.Started( + stepNumber = 10, + stepCount = 10, isSetupAssistantChecked = true, - isSetupAssistantButtonEnabled = true, - isStarting = false, ), ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupState.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupState.kt index f920a4aad3..ebf8eb122d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupState.kt @@ -1,13 +1,88 @@ package io.github.sds100.keymapper.base.expertmode -import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep - -data class ExpertModeSetupState( - val stepNumber: Int, - val stepCount: Int, - val step: SystemBridgeSetupStep, - val stepContent: StepContent, - val isSetupAssistantChecked: Boolean, - val isSetupAssistantButtonEnabled: Boolean, - val isStarting: Boolean, -) +sealed class ExpertModeSetupState { + abstract val stepNumber: Int + abstract val stepCount: Int + abstract val isSetupAssistantChecked: Boolean + abstract val isSetupAssistantButtonEnabled: Boolean + + data class AccessibilityService(override val stepNumber: Int, override val stepCount: Int) : + ExpertModeSetupState() { + override val isSetupAssistantChecked: Boolean = false + override val isSetupAssistantButtonEnabled: Boolean = false + } + + data class NotificationPermission( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class LocalNetworkPermission( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class SamsungAutoBlocker( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class DeveloperOptions( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class WifiNetwork( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class WirelessDebugging( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + val showSamsungAutoBlockerWarning: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class AdbPairing( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class StartService( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + val isStarting: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = true + } + + data class Started( + override val stepNumber: Int, + override val stepCount: Int, + override val isSetupAssistantChecked: Boolean, + ) : ExpertModeSetupState() { + override val isSetupAssistantButtonEnabled: Boolean = false + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt index 28a16c14d4..9fe4e6111f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt @@ -1,18 +1,5 @@ package io.github.sds100.keymapper.base.expertmode -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Accessibility -import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.Build -import androidx.compose.material.icons.rounded.CheckCircleOutline -import androidx.compose.material.icons.rounded.Lan -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.Notifications -import androidx.compose.material.icons.rounded.PlayArrow -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons -import io.github.sds100.keymapper.base.utils.ui.compose.icons.SignalWifiNotConnected import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep @@ -25,14 +12,13 @@ import kotlinx.coroutines.flow.stateIn abstract class SystemBridgeSetupDelegateImpl( val viewModelScope: CoroutineScope, private val useCase: SystemBridgeSetupUseCase, - private val resourceProvider: ResourceProvider, -) : SystemBridgeSetupDelegate, - ResourceProvider by resourceProvider { +) : SystemBridgeSetupDelegate { override val setupState: StateFlow> = combine( useCase.nextSetupStep, useCase.isSetupAssistantEnabled, useCase.isSystemBridgeStarting, + useCase.hasSamsungAutoBlocker, ::buildState, ).stateIn( viewModelScope, @@ -43,19 +29,21 @@ abstract class SystemBridgeSetupDelegateImpl( override fun onSetupStepButtonClick() { // Do not check the latest value in the use case because there is significant latency // when it is checking whether it is paired - val currentStep = setupState.value.dataOrNull()?.step ?: return + val currentState = setupState.value.dataOrNull() ?: return - when (currentStep) { - SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> useCase.enableAccessibilityService() - SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> useCase.requestNotificationPermission() - SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> + when (currentState) { + is ExpertModeSetupState.AccessibilityService -> useCase.enableAccessibilityService() + is ExpertModeSetupState.NotificationPermission -> + useCase.requestNotificationPermission() + is ExpertModeSetupState.LocalNetworkPermission -> useCase.requestLocalNetworkPermission() - SystemBridgeSetupStep.DEVELOPER_OPTIONS -> useCase.enableDeveloperOptions() - SystemBridgeSetupStep.WIFI_NETWORK -> useCase.connectWifiNetwork() - SystemBridgeSetupStep.WIRELESS_DEBUGGING -> useCase.enableWirelessDebugging() - SystemBridgeSetupStep.ADB_PAIRING -> useCase.pairWirelessAdb() - SystemBridgeSetupStep.START_SERVICE -> useCase.startSystemBridgeWithAdb() - SystemBridgeSetupStep.STARTED -> onFinishClick() + is ExpertModeSetupState.SamsungAutoBlocker -> useCase.openSamsungAutoBlockerSettings() + is ExpertModeSetupState.DeveloperOptions -> useCase.enableDeveloperOptions() + is ExpertModeSetupState.WifiNetwork -> useCase.connectWifiNetwork() + is ExpertModeSetupState.WirelessDebugging -> useCase.enableWirelessDebugging() + is ExpertModeSetupState.AdbPairing -> useCase.pairWirelessAdb() + is ExpertModeSetupState.StartService -> useCase.startSystemBridgeWithAdb() + is ExpertModeSetupState.Started -> onFinishClick() } } @@ -65,155 +53,93 @@ abstract class SystemBridgeSetupDelegateImpl( useCase.toggleSetupAssistant() } - override fun getStepContent(step: SystemBridgeSetupStep): StepContent { - return when (step) { - SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_enable_accessibility_service_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_enable_accessibility_service_description, - ), - icon = Icons.Rounded.Accessibility, - buttonText = getString( - R.string.expert_mode_setup_wizard_enable_accessibility_service_button, - ), - ) - - SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_enable_notification_permission_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_enable_notification_permission_description, - ), - icon = Icons.Rounded.Notifications, - buttonText = getString( - R.string.expert_mode_setup_wizard_enable_notification_permission_button, - ), - ) - - SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_local_network_permission_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_local_network_permission_description, - ), - icon = Icons.Rounded.Lan, - buttonText = getString( - R.string.expert_mode_setup_wizard_local_network_permission_button, - ), - ) - - SystemBridgeSetupStep.DEVELOPER_OPTIONS -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_enable_developer_options_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_enable_developer_options_description, - ), - icon = Icons.Rounded.Build, - buttonText = getString( - R.string.expert_mode_setup_wizard_go_to_settings_button, - ), - ) - - SystemBridgeSetupStep.WIFI_NETWORK -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_connect_wifi_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_connect_wifi_description, - ), - icon = KeyMapperIcons.SignalWifiNotConnected, - buttonText = getString( - R.string.expert_mode_setup_wizard_go_to_settings_button, - ), - ) - - SystemBridgeSetupStep.WIRELESS_DEBUGGING -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_enable_wireless_debugging_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_enable_wireless_debugging_description, - ), - icon = Icons.Rounded.BugReport, - buttonText = getString( - R.string.expert_mode_setup_wizard_go_to_settings_button, - ), - ) - - SystemBridgeSetupStep.ADB_PAIRING -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_pair_wireless_debugging_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_pair_wireless_debugging_description, - ), - icon = Icons.Rounded.Link, - buttonText = getString( - R.string.expert_mode_setup_wizard_go_to_settings_button, - ), - ) - - SystemBridgeSetupStep.START_SERVICE -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_start_service_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_start_service_description, - ), - icon = Icons.Rounded.PlayArrow, - buttonText = getString( - R.string.expert_mode_root_detected_button_start_service, - ), - ) - - SystemBridgeSetupStep.STARTED -> StepContent( - title = getString( - R.string.expert_mode_setup_wizard_complete_title, - ), - message = getString( - R.string.expert_mode_setup_wizard_complete_text, - ), - icon = Icons.Rounded.CheckCircleOutline, - buttonText = getString( - R.string.expert_mode_setup_wizard_complete_button, - ), - ) - } + override fun onSamsungAutoBlockerWarningClick() { + useCase.openSamsungAutoBlockerSettings() } private fun buildState( step: SystemBridgeSetupStep, isSetupAssistantUserEnabled: Boolean, isStarting: Boolean, + hasSamsungAutoBlocker: Boolean, ): State.Data { - // Uncheck the setup assistant if the accessibility service is disabled since it is - // required for the setup assistant to work - val isSetupAssistantChecked = if (step == SystemBridgeSetupStep.ACCESSIBILITY_SERVICE) { - false - } else { - isSetupAssistantUserEnabled + val stepNumber = step.stepIndex + 1 + val stepCount = SystemBridgeSetupStep.entries.size + + val state = when (step) { + SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> + ExpertModeSetupState.AccessibilityService( + stepNumber = stepNumber, + stepCount = stepCount, + ) + + SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> + ExpertModeSetupState.NotificationPermission( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) + + SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> + ExpertModeSetupState.LocalNetworkPermission( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) + + SystemBridgeSetupStep.SAMSUNG_AUTO_BLOCKER -> + ExpertModeSetupState.SamsungAutoBlocker( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) + + SystemBridgeSetupStep.DEVELOPER_OPTIONS -> + ExpertModeSetupState.DeveloperOptions( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) + + SystemBridgeSetupStep.WIFI_NETWORK -> + ExpertModeSetupState.WifiNetwork( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) + + SystemBridgeSetupStep.WIRELESS_DEBUGGING -> + ExpertModeSetupState.WirelessDebugging( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + showSamsungAutoBlockerWarning = hasSamsungAutoBlocker, + ) + + SystemBridgeSetupStep.ADB_PAIRING -> + ExpertModeSetupState.AdbPairing( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) + + SystemBridgeSetupStep.START_SERVICE -> + ExpertModeSetupState.StartService( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + isStarting = isStarting, + ) + + SystemBridgeSetupStep.STARTED -> + ExpertModeSetupState.Started( + stepNumber = stepNumber, + stepCount = stepCount, + isSetupAssistantChecked = isSetupAssistantUserEnabled, + ) } - val stepContent = getStepContent(step) - - return State.Data( - ExpertModeSetupState( - stepNumber = step.stepIndex + 1, - stepCount = SystemBridgeSetupStep.entries.size, - step = step, - stepContent = stepContent, - isSetupAssistantChecked = isSetupAssistantChecked, - isSetupAssistantButtonEnabled = - step != SystemBridgeSetupStep.ACCESSIBILITY_SERVICE && - step != SystemBridgeSetupStep.STARTED, - isStarting = isStarting, - ), - ) + return State.Data(state) } } @@ -221,5 +147,5 @@ interface SystemBridgeSetupDelegate { val setupState: StateFlow> fun onSetupStepButtonClick() fun onSetupAssistantClick() - fun getStepContent(step: SystemBridgeSetupStep): StepContent + fun onSamsungAutoBlockerWarningClick() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt index 24a469eef4..8318d3afa9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt @@ -16,6 +16,8 @@ import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter +import io.github.sds100.keymapper.system.apps.isAppInstalledFlow import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter @@ -24,6 +26,7 @@ import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest @@ -42,6 +45,7 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( private val permissionAdapter: PermissionAdapter, private val accessibilityServiceAdapter: AccessibilityServiceAdapter, private val networkAdapter: NetworkAdapter, + private val packageManagerAdapter: PackageManagerAdapter, private val clock: Clock, ) : SystemBridgeSetupUseCase { @@ -51,6 +55,23 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( * FUNCTION_NONE in that file. */ private const val USB_FUNCTION_NONE = 0 + + /** + * Samsung One UI's Auto Blocker security feature. If installed it can silently + * prevent Wireless Debugging from staying enabled. + */ + private const val SAMSUNG_AUTO_BLOCKER_PACKAGE = "com.samsung.android.rampart" + } + + override val hasSamsungAutoBlocker: Flow = + packageManagerAdapter.isAppInstalledFlow(SAMSUNG_AUTO_BLOCKER_PACKAGE) + + // Not persisted. The user should be re-prompted every time they open the setup wizard. + private val isSamsungAutoBlockerDismissed = MutableStateFlow(false) + + override fun openSamsungAutoBlockerSettings() { + isSamsungAutoBlockerDismissed.value = true + systemBridgeSetupController.openAutoBlockerSettings() } override val isWarningUnderstood: Flow = @@ -139,27 +160,35 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( @RequiresApi(Build.VERSION_CODES.R) private fun getNextStepFlow(): Flow = accessibilityServiceAdapter.state.flatMapLatest { accessibilityServiceState -> - combine( - isNotificationPermissionGranted, - isLocalNetworkPermissionGranted, - systemBridgeSetupController.isDeveloperOptionsEnabled, - networkAdapter.isWifiConnected, - systemBridgeSetupController.isWirelessDebuggingEnabled, - ) { - isNotificationGranted, - isLocalNetworkGranted, - isDeveloperOptionsEnabled, - isWifiConnected, - isWirelessDebuggingEnabled, + combine(hasSamsungAutoBlocker, isSamsungAutoBlockerDismissed) { + hasAutoBlocker, + isDismissed, -> - getNextStep( - accessibilityServiceState = accessibilityServiceState, - isNotificationPermissionGranted = isNotificationGranted, - isLocalNetworkPermissionGranted = isLocalNetworkGranted, - isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, - isWifiConnected = isWifiConnected, - isWirelessDebuggingEnabled = isWirelessDebuggingEnabled, - ) + hasAutoBlocker && !isDismissed + }.flatMapLatest { needsSamsungAutoBlockerStep -> + combine( + isNotificationPermissionGranted, + isLocalNetworkPermissionGranted, + systemBridgeSetupController.isDeveloperOptionsEnabled, + networkAdapter.isWifiConnected, + systemBridgeSetupController.isWirelessDebuggingEnabled, + ) { + isNotificationGranted, + isLocalNetworkGranted, + isDeveloperOptionsEnabled, + isWifiConnected, + isWirelessDebuggingEnabled, + -> + getNextStep( + accessibilityServiceState = accessibilityServiceState, + needsSamsungAutoBlockerStep = needsSamsungAutoBlockerStep, + isNotificationPermissionGranted = isNotificationGranted, + isLocalNetworkPermissionGranted = isLocalNetworkGranted, + isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, + isWifiConnected = isWifiConnected, + isWirelessDebuggingEnabled = isWirelessDebuggingEnabled, + ) + } } } @@ -321,6 +350,7 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( @RequiresApi(Build.VERSION_CODES.R) private fun getNextStep( accessibilityServiceState: AccessibilityServiceState, + needsSamsungAutoBlockerStep: Boolean, isNotificationPermissionGranted: Boolean, isLocalNetworkPermissionGranted: Boolean, isDeveloperOptionsEnabled: Boolean, @@ -336,6 +366,8 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( !isLocalNetworkPermissionGranted -> SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION + needsSamsungAutoBlockerStep -> SystemBridgeSetupStep.SAMSUNG_AUTO_BLOCKER + !isDeveloperOptionsEnabled -> SystemBridgeSetupStep.DEVELOPER_OPTIONS !isWifiConnected -> SystemBridgeSetupStep.WIFI_NETWORK @@ -387,6 +419,9 @@ interface SystemBridgeSetupUseCase { val isLocalNetworkPermissionGranted: Flow fun requestLocalNetworkPermission() + val hasSamsungAutoBlocker: Flow + fun openSamsungAutoBlockerSettings() + fun stopSystemBridge() fun enableAccessibilityService() fun enableDeveloperOptions() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonAppearance.kt b/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonAppearance.kt index 8c28d0d861..6a1703119d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonAppearance.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonAppearance.kt @@ -18,6 +18,8 @@ data class FloatingButtonAppearance( } fun isInvisible(): Boolean { - return text.isBlank() && borderOpacity == 0f && backgroundOpacity == 0f + // See issue #2285. It is still invisible if the background is opaque because it is + // black. + return text.isBlank() && borderOpacity == 0f } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/EnableGroupByIntentScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/EnableGroupByIntentScreen.kt new file mode 100644 index 0000000000..2fcc23bab4 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/EnableGroupByIntentScreen.kt @@ -0,0 +1,256 @@ +package io.github.sds100.keymapper.base.groups + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FolderOpen +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.IntentApi +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.keymaps.AdbSection +import io.github.sds100.keymapper.base.keymaps.IntentScreen +import io.github.sds100.keymapper.base.keymaps.buildAdbCommand +import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock +import io.github.sds100.keymapper.base.utils.ui.compose.OptionPageButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnableGroupByIntentScreen( + modifier: Modifier = Modifier, + viewModel: EnableGroupByIntentViewModel, +) { + val breadcrumbs by viewModel.breadcrumbs.collectAsStateWithLifecycle() + val groups by viewModel.groups.collectAsStateWithLifecycle() + val selectedGroupUid by viewModel.selectedGroupUid.collectAsStateWithLifecycle() + val isGroupPickerVisible by viewModel.isGroupPickerVisible.collectAsStateWithLifecycle() + + if (isGroupPickerVisible) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + SelectGroupBottomSheet( + sheetState = sheetState, + breadcrumbs = breadcrumbs, + groups = groups, + canConfirm = selectedGroupUid != null, + onGroupClick = viewModel::onGroupClick, + onDismissRequest = viewModel::onDismissGroupPicker, + ) + } + + EnableGroupByIntentScreen( + modifier = modifier, + selectedGroupUid = selectedGroupUid, + onSelectGroupClick = viewModel::onSelectGroupClick, + onBackClick = viewModel::onBackClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnableGroupByIntentScreen( + modifier: Modifier = Modifier, + selectedGroupUid: String?, + onSelectGroupClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, +) { + val packageName = LocalContext.current.packageName + val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label) + + IntentScreen( + modifier = modifier, + title = stringResource(R.string.enable_disable_group_by_intent_title), + text = stringResource(R.string.intent_screen_enable_group_message), + onBackClick = onBackClick, + ) { + Text( + text = stringResource(R.string.intent_screen_target_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_package), + code = packageName, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_class), + code = IntentApi.GROUP_RECEIVER_CLASS, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + Text( + text = stringResource(R.string.intent_screen_action_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action_enable), + code = IntentApi.ACTION_ENABLE_GROUP, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action_disable), + code = IntentApi.ACTION_DISABLE_GROUP, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action_toggle), + code = IntentApi.ACTION_TOGGLE_GROUP, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + Text( + text = stringResource(R.string.intent_screen_extras_explanation_group), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + OptionPageButton( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.select_group_by_intent_title), + text = stringResource(R.string.select_group_by_intent_summary), + icon = Icons.Rounded.FolderOpen, + onClick = onSelectGroupClick, + ) + + if (selectedGroupUid == null) { + Text( + text = stringResource(R.string.intent_screen_select_group_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + CodeBlock( + label = stringResource(R.string.intent_screen_label_extra_name), + code = IntentApi.EXTRA_GROUP_UID, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_extra_value_group), + code = selectedGroupUid, + clipboardLabel = clipboardLabel, + ) + + AdbSection( + command = buildAdbCommand( + packageName = packageName, + receiverClass = IntentApi.GROUP_RECEIVER_CLASS, + action = IntentApi.ACTION_TOGGLE_GROUP, + extraName = IntentApi.EXTRA_GROUP_UID, + extraValue = selectedGroupUid, + ), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectGroupBottomSheet( + sheetState: SheetState, + breadcrumbs: List, + groups: List, + canConfirm: Boolean, + onGroupClick: (String?) -> Unit = {}, + onDismissRequest: () -> Unit = {}, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(modifier = Modifier.height(12.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(R.string.select_group_by_intent_title), + style = MaterialTheme.typography.titleLarge, + ) + + GroupBreadcrumbRow(groups = breadcrumbs, onGroupClick = onGroupClick) + + GroupRow( + modifier = Modifier.fillMaxWidth(), + groups = groups, + onGroupClick = onGroupClick, + showNewGroup = false, + showThisGroupButton = canConfirm, + onThisGroupClick = onDismissRequest, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Preview(heightDp = 1200) +@Composable +private fun EnableGroupByIntentScreenPreview() { + KeyMapperTheme { + EnableGroupByIntentScreen( + selectedGroupUid = null, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun SelectGroupBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + + SelectGroupBottomSheet( + sheetState = sheetState, + breadcrumbs = listOf(GroupListItemModel(uid = "1", name = "Lockscreen")), + groups = listOf(GroupListItemModel(uid = "2", name = "Key Mapper")), + canConfirm = true, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/EnableGroupByIntentViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/EnableGroupByIntentViewModel.kt new file mode 100644 index 0000000000..e40a78bc81 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/EnableGroupByIntentViewModel.kt @@ -0,0 +1,62 @@ +package io.github.sds100.keymapper.base.groups + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel +class EnableGroupByIntentViewModel @Inject constructor( + private val selectGroup: SelectGroupUseCase, + navigationProvider: NavigationProvider, +) : ViewModel(), + NavigationProvider by navigationProvider { + + private val groupFamily: StateFlow = selectGroup.selectionGroupFamily + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + GroupFamily(null, emptyList(), emptyList()), + ) + + val breadcrumbs: StateFlow> = groupFamily.map { family -> + family.parents.plus(family.group).filterNotNull().map { + GroupListItemModel(it.uid, it.name) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val groups: StateFlow> = groupFamily.map { family -> + family.children.map { GroupListItemModel(it.uid, it.name) } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val selectedGroupUid: StateFlow = groupFamily.map { it.group?.uid } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val _isGroupPickerVisible = MutableStateFlow(false) + val isGroupPickerVisible: StateFlow = _isGroupPickerVisible.asStateFlow() + + fun onSelectGroupClick() { + _isGroupPickerVisible.update { true } + } + + fun onDismissGroupPicker() { + _isGroupPickerVisible.update { false } + } + + fun onGroupClick(groupUid: String?) { + viewModelScope.launch { selectGroup.openSelectionGroup(groupUid) } + } + + fun onBackClick() { + viewModelScope.launch { popBackStack() } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCase.kt new file mode 100644 index 0000000000..5d3b3ee26f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCase.kt @@ -0,0 +1,10 @@ +package io.github.sds100.keymapper.base.groups + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface SelectGroupUseCase { + val selectionGroupUid: StateFlow + val selectionGroupFamily: Flow + suspend fun openSelectionGroup(uid: String?) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt new file mode 100644 index 0000000000..e0add1269e --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt @@ -0,0 +1,81 @@ +package io.github.sds100.keymapper.base.groups + +import io.github.sds100.keymapper.data.repositories.GroupRepository +import java.util.LinkedList +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +@OptIn(ExperimentalCoroutinesApi::class) +class SelectGroupUseCaseImpl @Inject constructor(private val groupRepository: GroupRepository) : + SelectGroupUseCase { + + private val _selectionGroupUid = MutableStateFlow(null) + override val selectionGroupUid: StateFlow = _selectionGroupUid.asStateFlow() + + 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 getGroupFamily(groupUid: String?): Flow { + // If the current group is the root then just get the subgroups. + if (groupUid == null) { + return groupRepository.getGroupsByParent(null).map { childrenEntities -> + val children = childrenEntities + .map(GroupEntityMapper::fromEntity) + .sortedByDescending { it.lastOpenedDate } + GroupFamily(group = null, children = children, parents = emptyList()) + } + } else { + val parents = getParentsRecursively(groupUid) + + return groupRepository.getGroupWithChildren(groupUid).map { groupWithChildren -> + val group = GroupEntityMapper.fromEntity(groupWithChildren.group) + val children = groupWithChildren.children.map(GroupEntityMapper::fromEntity) + + GroupFamily(group, children = children, parents = parents) + } + } + } + + private suspend fun getParentsRecursively(groupUid: String?): List { + val list = LinkedList() + var count = 0 + + if (groupUid == null) { + return emptyList() + } + + var currentGroup: String? = groupRepository.getGroup(groupUid)?.parentUid + + while (count < 1000) { + if (currentGroup == null) { + break + } + + val group = groupRepository.getGroup(currentGroup) ?: break + list.addFirst(GroupEntityMapper.fromEntity(group)) + currentGroup = group.parentUid + + count++ + } + + return list + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt index 418b72d2ff..6d74ddc5ae 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt @@ -40,7 +40,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.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -173,7 +172,11 @@ fun HomeKeyMapListScreen( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val ctx = LocalContext.current - var keyMapListBottomPadding by remember { mutableStateOf(100.dp) } + val keyMapListBottomPadding = if (state.appBarState is KeyMapAppBarState.Selecting) { + 300.dp + } else { + 100.dp + } val lazyListState = rememberLazyListState() HomeKeyMapListScreen( @@ -214,7 +217,6 @@ fun HomeKeyMapListScreen( onNotConstraintClick = viewModel::onNotGroupConstraintClick, onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, onFixClick = viewModel::onFixGroupConstraintsClick, - onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, ) }, footerText = stringResource(R.string.home_key_map_list_footer_text), @@ -244,6 +246,7 @@ fun HomeKeyMapListScreen( }, onInputMethodPickerClick = viewModel::showInputMethodPicker, onTogglePausedClick = viewModel::onTogglePausedClick, + onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, onBackClick = { if (!viewModel.onBackClick()) { finishActivity() @@ -466,6 +469,7 @@ fun sameKeyMapListItems(): List { isSelected = true, KeyMapListItemModel.Content( uid = "0", + name = "Custom key map name", triggerKeys = listOf("Volume down", "Volume up", "Volume down"), triggerSeparatorIcon = Icons.AutoMirrored.Outlined.ArrowForward, actions = listOf( @@ -542,7 +546,7 @@ fun sameKeyMapListItems(): List { "Vibrate when keys are initially pressed and again when long pressed", ), isEnabled = true, - hasError = false, + hasError = true, ), ), KeyMapListItemModel( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt index 56d9958e33..b2e8055e26 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt @@ -10,8 +10,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.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -24,17 +23,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding -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.Sort import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Campaign import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Done -import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.IosShare @@ -51,16 +45,19 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -72,22 +69,22 @@ 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.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics 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.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.groups.DeleteGroupDialog +import io.github.sds100.keymapper.base.utils.ui.compose.EditableAppBarTextField import io.github.sds100.keymapper.base.utils.ui.compose.icons.Import import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons import kotlinx.coroutines.launch @@ -112,6 +109,7 @@ fun KeyMapListAppBar( onEditGroupNameClick: () -> Unit = {}, onDeleteGroupClick: () -> Unit = {}, onReportBugClick: () -> Unit = {}, + onKeyMapsEnabledChange: (Boolean) -> Unit = {}, ) { BackHandler(onBack = onBackClick) @@ -246,33 +244,40 @@ fun KeyMapListAppBar( AnimatedVisibility(!state.isEditingGroupName) { var expandedDropdown by rememberSaveable { mutableStateOf(false) } - AppBarActions( - showWhatsNew = false, - onWhatsNewClick, - onMenuClick = { expandedDropdown = true }, - dropdownMenuContent = { - ChildGroupDropdownMenu( - expanded = expandedDropdown, - onSortClick = { - expandedDropdown = false - onSortClick() - }, - onSettingsClick = { - expandedDropdown = false - onSettingsClick() - }, - onAboutClick = { - expandedDropdown = false - onAboutClick() - }, - onDismissRequest = { expandedDropdown = false }, - onDeleteGroupClick = { - expandedDropdown = false - showDeleteGroupDialog = true - }, - ) - }, - ) + Row { + KeyMapsEnabledSwitch( + state = state.keyMapsEnabled, + onChange = onKeyMapsEnabledChange, + ) + + AppBarActions( + showWhatsNew = false, + onWhatsNewClick, + onMenuClick = { expandedDropdown = true }, + dropdownMenuContent = { + ChildGroupDropdownMenu( + expanded = expandedDropdown, + onSortClick = { + expandedDropdown = false + onSortClick() + }, + onSettingsClick = { + expandedDropdown = false + onSettingsClick() + }, + onAboutClick = { + expandedDropdown = false + onAboutClick() + }, + onDismissRequest = { expandedDropdown = false }, + onDeleteGroupClick = { + expandedDropdown = false + showDeleteGroupDialog = true + }, + ) + }, + ) + } } }, ) @@ -354,17 +359,23 @@ private fun ChildGroupAppBar( ) } - GroupNameRow( + Spacer(Modifier.width(4.dp)) + + EditableAppBarTextField( modifier = Modifier.weight(1f), value = groupName, onValueChange = onValueChange, placeholder = placeholder, - onRenameClick = onRenameClick, + onConfirmClick = onRenameClick, error = error, isEditing = isEditingGroupName, onEditClick = onEditClick, + confirmContentDescription = stringResource(R.string.home_app_bar_save_group_name), + editContentDescription = stringResource(R.string.home_app_bar_edit_group_name), ) + Spacer(Modifier.width(4.dp)) + AnimatedVisibility(visible = !isEditingGroupName) { actions() } @@ -397,6 +408,7 @@ private fun SelectingAppBar( OutlinedButton( modifier = Modifier.padding(horizontal = 8.dp), onClick = onSelectAllClick, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { val text = if (state.isAllSelected) { stringResource(R.string.home_app_bar_deselect_all) @@ -410,6 +422,38 @@ private fun SelectingAppBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KeyMapsEnabledSwitch(state: SelectedKeyMapsEnabled?, onChange: (Boolean) -> Unit) { + val text = when (state) { + SelectedKeyMapsEnabled.ALL -> stringResource(R.string.home_enabled_key_maps_enabled) + SelectedKeyMapsEnabled.MIXED -> stringResource(R.string.home_enabled_key_maps_mixed) + SelectedKeyMapsEnabled.NONE, null -> stringResource( + R.string.home_enabled_key_maps_disabled, + ) + } + val tooltipState = rememberTooltipState() + val scope = rememberCoroutineScope() + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Below, + ), + tooltip = { PlainTooltip { Text(text) } }, + state = tooltipState, + ) { + Switch( + modifier = Modifier.semantics { contentDescription = text }, + checked = state == SelectedKeyMapsEnabled.ALL, + onCheckedChange = { + onChange(it) + scope.launch { tooltipState.show() } + }, + enabled = state != null, + ) + } +} + @Composable private fun AppBarActions( showWhatsNew: Boolean, @@ -438,126 +482,6 @@ private fun AppBarActions( } } -@Composable -private fun GroupNameRow( - modifier: Modifier = Modifier, - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit = {}, - placeholder: String, - isEditing: Boolean, - onRenameClick: () -> Unit, - onEditClick: () -> Unit = {}, - error: String? = null, -) { - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(isEditing) { - focusRequester.requestFocus() - } - - AnimatedContent(modifier = modifier, targetState = isEditing) { isEditing -> - Row( - Modifier - .height(IntrinsicSize.Min) - .fillMaxWidth(), - verticalAlignment = Alignment.Top, - ) { - val interactionSource = remember { MutableInteractionSource() } - - // Use a custom text field so the content padding can be customised. - BasicTextField( - modifier = Modifier - .focusRequester(focusRequester) - .height(IntrinsicSize.Max) - .then( - if (isEditing) { - Modifier.weight(1f) - } else { - Modifier.weight(1f, fill = false) - }, - ), - value = value, - onValueChange = onValueChange, - textStyle = MaterialTheme.typography.titleLarge.copy( - color = LocalContentColor.current, - ), - enabled = isEditing, - keyboardActions = KeyboardActions(onDone = { onRenameClick() }), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - showKeyboardOnFocus = true, - ), - singleLine = true, - maxLines = 1, - interactionSource = interactionSource, - ) { innerTextField -> - @OptIn(ExperimentalMaterial3Api::class) - OutlinedTextFieldDefaults.DecorationBox( - value = value.text, - placeholder = { - Text( - placeholder, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - color = OutlinedTextFieldDefaults.colors().disabledPlaceholderColor, - ) - }, - innerTextField = { - Box( - Modifier - .width(IntrinsicSize.Min) - .height(48.dp), - contentAlignment = Alignment.CenterStart, - ) { innerTextField() } - }, - singleLine = true, - colors = if (isEditing) { - OutlinedTextFieldDefaults.colors() - } else { - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - disabledTextColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - }, - isError = error != null, - enabled = isEditing, - supportingText = if (error == null) { - null - } else { - { Text(error, maxLines = 1) } - }, - visualTransformation = VisualTransformation.None, - interactionSource = interactionSource, - contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( - top = 0.dp, - bottom = 0.dp, - end = 4.dp, - start = 8.dp, - ), - ) - } - - if (isEditing) { - IconButton(onClick = onRenameClick) { - Icon( - Icons.Rounded.Done, - contentDescription = stringResource(R.string.home_app_bar_save_group_name), - ) - } - } else { - IconButton(onClick = onEditClick) { - Icon( - Icons.Rounded.Edit, - contentDescription = stringResource(R.string.home_app_bar_edit_group_name), - ) - } - } - } - } -} - @Composable private fun AppBarStatus( isPaused: Boolean, @@ -911,7 +835,7 @@ private fun HomeStateWarningsPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@PreviewLightDark @Composable private fun HomeStateSelectingPreview() { val state = KeyMapAppBarState.Selecting( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt index b67df2198e..d2bf98b012 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt @@ -11,14 +11,12 @@ 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.material.icons.Icons import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -69,7 +67,6 @@ fun KeyMapListHeader( onNotConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, onFixClick: () -> Unit = {}, - onKeyMapsEnabledChange: (Boolean) -> Unit = {}, ) { // This is taken from the AppBar color code so the header is the same color as the app bar // above it. @@ -114,7 +111,6 @@ fun KeyMapListHeader( onNotConstraintClick = onNotConstraintClick, onConstraintModeChanged = onConstraintModeChanged, onFixClick = onFixClick, - onKeyMapsEnabledChange = onKeyMapsEnabledChange, ) // The groups and breadcrumbs are shown in the selection bottom sheet instead. @@ -170,7 +166,6 @@ private fun ChildGroupHeader( onNotConstraintClick: (String) -> Unit, onConstraintModeChanged: (ConstraintMode) -> Unit, onFixClick: () -> Unit, - onKeyMapsEnabledChange: (Boolean) -> Unit, ) { val enabled = !state.isEditingGroupName @@ -184,8 +179,7 @@ private fun ChildGroupHeader( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.constraints.isNotEmpty()) { Row( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -218,7 +212,7 @@ private fun ChildGroupHeader( Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp), + .padding(bottom = 8.dp, start = 8.dp, end = 8.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { @@ -231,42 +225,12 @@ private fun ChildGroupHeader( ) AnimatedVisibility(visible = state.constraints.size > 1) { - Row(verticalAlignment = Alignment.CenterVertically) { - ConstraintModeButtons( - mode = state.constraintMode, - onSelectMode = onConstraintModeChanged, - colors = modeColors, - ) - } - } - - Spacer(Modifier.width(16.dp)) - - val text = when (state.keyMapsEnabled) { - SelectedKeyMapsEnabled.ALL -> stringResource( - R.string.home_enabled_key_maps_enabled, - ) - - SelectedKeyMapsEnabled.MIXED -> stringResource( - R.string.home_enabled_key_maps_mixed, - ) - - SelectedKeyMapsEnabled.NONE, null -> stringResource( - R.string.home_enabled_key_maps_disabled, + ConstraintModeButtons( + mode = state.constraintMode, + onSelectMode = onConstraintModeChanged, + colors = modeColors, ) } - - Switch( - checked = state.keyMapsEnabled == SelectedKeyMapsEnabled.ALL, - onCheckedChange = onKeyMapsEnabledChange, - enabled = state.keyMapsEnabled != null, - ) - - Spacer(Modifier.width(16.dp)) - - Text(text = text, style = MaterialTheme.typography.bodyMedium) - - Spacer(Modifier.width(16.dp)) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt index 5475cecad3..1f28fa26b5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.Placeholder 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.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -96,6 +97,32 @@ fun KeyMapListItem( .fillMaxWidth() .padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 10.dp), ) { + val showName = model.content.name.isNotBlank() + + if (showName) { + Row( + modifier = Modifier.heightIn(min = chipHeight), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = model.content.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (model.content.isEnabled && model.content.hasError) { + CompactErrorButton(onClick = onFixClick) { + Text(stringResource(R.string.button_fix)) + } + } + } + Spacer(Modifier.height(8.dp)) + } + Row( modifier = Modifier.heightIn(min = chipHeight), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -108,7 +135,7 @@ fun KeyMapListItem( separator = model.content.triggerSeparatorIcon, ) - if (model.content.isEnabled && model.content.hasError) { + if (!showName && model.content.isEnabled && model.content.hasError) { CompactErrorButton(onClick = onFixClick) { Text(stringResource(R.string.button_fix)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt index ab3735aa07..ea6f233970 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.home 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.rounded.Timelapse import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.actions.ActionUiHelper @@ -35,6 +36,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.TimeUtils class KeyMapListItemCreator( private val displayMapping: DisplayKeyMapUseCase, @@ -107,6 +109,7 @@ class KeyMapListItemCreator( options = options, isEnabled = keyMap.isEnabled, hasError = hasError, + name = keyMap.name, ) } @@ -117,44 +120,18 @@ class KeyMapListItemCreator( showDeviceDescriptors: Boolean, errorSnapshot: ActionErrorSnapshot, ): List = sequence { - val midDot = getString(R.string.middot) - val actionErrors = if (keyMap.isEnabled) { errorSnapshot.getErrors(keyMap.actionList.map { it.data }) } else { emptyMap() } - for (action in keyMap.actionList) { - val actionTitle: String = if (action.multiplier != null) { - "${action.multiplier}x ${actionUiHelper.getTitle(action, showDeviceDescriptors)}" + for ((index, action) in keyMap.actionList.withIndex()) { + val title = actionUiHelper.getChipTitle(keyMap, action, showDeviceDescriptors) + val chipText = if (action.multiplier != null) { + "${action.multiplier}x $title" } else { - actionUiHelper.getTitle(action, showDeviceDescriptors) - } - - val chipText = buildString { - append(actionTitle) - - actionUiHelper.getOptionLabels(keyMap, action).forEach { label -> - append(" $midDot ") - - append(label) - } - - if (keyMap.isDelayBeforeNextActionAllowed() && - action.delayBeforeNextAction != null - ) { - if (this@buildString.isNotBlank()) { - append(" $midDot ") - } - - append( - getString( - R.string.action_title_wait_ms, - action.delayBeforeNextAction, - ), - ) - } + title } val icon: ComposeIconInfo = actionUiHelper.getIcon(action.data) @@ -173,9 +150,30 @@ class KeyMapListItemCreator( } yield(chip) + + // A delay after the last action does nothing so do not show it. + if (keyMap.isDelayBeforeNextActionAllowed() && + action.delayBeforeNextAction != null && + index < keyMap.actionList.lastIndex + ) { + yield( + ComposeChipModel.Normal( + id = "${action.uid}-delay", + icon = ComposeIconInfo.Vector(Icons.Rounded.Timelapse), + text = delayText(action.delayBeforeNextAction), + isEnabled = keyMap.isEnabled && action.isEnabled, + ), + ) + } } }.toList() + private fun delayText(ms: Int): String = if (ms < 1000) { + getString(R.string.action_chip_delay_ms, ms) + } else { + getString(R.string.action_chip_delay_secs, TimeUtils.formatSeconds(ms)) + } + fun buildConstraintChipList( constraintState: ConstraintState, errorSnapshot: ConstraintErrorSnapshot, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt index da03180b3f..e5f195e56c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt @@ -18,6 +18,7 @@ import io.github.sds100.keymapper.base.groups.Group import io.github.sds100.keymapper.base.groups.GroupEntityMapper import io.github.sds100.keymapper.base.groups.GroupFamily import io.github.sds100.keymapper.base.groups.GroupWithState +import io.github.sds100.keymapper.base.groups.SelectGroupUseCase import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.keymaps.KeyMapEntityMapper @@ -54,15 +55,16 @@ class ListKeyMapsUseCaseImpl @Inject constructor( private val backupManager: BackupManager, private val backupRestoreMappingsUseCase: BackupRestoreMappingsUseCase, private val resourceProvider: ResourceProvider, + selectGroupUseCase: SelectGroupUseCase, displayKeyMapUseCase: DisplayKeyMapUseCase, ) : ListKeyMapsUseCase, + SelectGroupUseCase by selectGroupUseCase, DisplayKeyMapUseCase by displayKeyMapUseCase { private val keyMapListGroupUid = MutableStateFlow(null) - private val selectionGroupUid = MutableStateFlow(null) - private fun setCurrentGroup(groupUid: String?) { + private suspend fun setCurrentGroup(groupUid: String?) { keyMapListGroupUid.update { groupUid } - selectionGroupUid.update { groupUid } + openSelectionGroup(groupUid) } private suspend fun getGroupFamily(groupUid: String?): Flow { @@ -162,20 +164,6 @@ class ListKeyMapsUseCaseImpl @Inject constructor( ) } - 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 @@ -469,7 +457,9 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } -interface ListKeyMapsUseCase : DisplayKeyMapUseCase { +interface ListKeyMapsUseCase : + DisplayKeyMapUseCase, + SelectGroupUseCase { val keyMapGroup: Flow suspend fun newGroup() @@ -485,8 +475,6 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { fun enableGroupKeyMaps() fun disableGroupKeyMaps() - 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) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt index 756cc3e2fd..90b816fd94 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt @@ -8,10 +8,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll @@ -69,7 +69,7 @@ fun WhatsNewDialogContent( val uriHandler = LocalUriHandler.current val changelogUrl = stringResource(R.string.url_changelog) - Column { + Column(modifier = modifier.widthIn(max = 400.dp)) { Spacer(Modifier.height(16.dp)) Column( @@ -125,7 +125,7 @@ fun WhatsNewDialogContent( @Composable private fun WhatsNewHeader(modifier: Modifier = Modifier, versionName: String) { Row( - modifier = modifier.fillMaxWidth(), + modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt index 67ae276bcd..6d00070d5c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt @@ -21,13 +21,8 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape 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.rounded.Check -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,7 +31,6 @@ import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -52,6 +46,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -65,55 +61,64 @@ import kotlinx.coroutines.launch @Composable fun BaseConfigKeyMapScreen( modifier: Modifier = Modifier, - isKeyMapEnabled: Boolean, + state: ConfigKeyMapScreenState, + onEditNameClick: () -> Unit = {}, + onConfirmNameClick: (String) -> Unit = {}, + onCancelEditNameClick: () -> Unit = {}, onKeyMapEnabledChange: (Boolean) -> Unit = {}, + onUndoClick: () -> Unit = {}, + onRedoClick: () -> Unit = {}, triggerScreen: @Composable () -> Unit, actionsScreen: @Composable () -> Unit, constraintsScreen: @Composable () -> Unit, optionsScreen: @Composable () -> Unit, onBackClick: () -> Unit = {}, - onDoneClick: () -> Unit = {}, snackbarHostState: SnackbarHostState = SnackbarHostState(), - showActionPulse: Boolean = false, ) { val scope = rememberCoroutineScope() + + // Store the name here while the user is typing instead of sending it to the view model state + // because that would mean each character pushes on a new state for undo/redo. + var nameDraft by remember { mutableStateOf(TextFieldValue(state.name)) } + + // Reset the draft whenever the saved name changes (e.g. undo/redo) or editing starts/stops. + LaunchedEffect(state.name, state.isEditingName) { + // Show the start of the name when not editing, and put the cursor at the end when editing. + val cursor = if (state.isEditingName) state.name.length else 0 + nameDraft = TextFieldValue(state.name, selection = TextRange(cursor)) + } + val triggerHelpUrl = stringResource(R.string.url_trigger_guide) val actionsHelpUrl = stringResource(R.string.url_action_guide) val constraintsHelpUrl = stringResource(R.string.url_constraints_guide) val optionsHelpUrl = stringResource(R.string.url_trigger_options_guide) - var currentTab: ConfigKeyMapTab? by remember { mutableStateOf(null) } - val uriHandler = LocalUriHandler.current - val ctx = LocalContext.current + // Back cancels editing the name instead of leaving the screen. + val onBack = if (state.isEditingName) { + onCancelEditNameClick + } else { + onBackClick + } - BackHandler(onBack = onBackClick) + BackHandler(onBack = onBack) Scaffold( modifier.displayCutoutPadding(), snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { ConfigKeyMapAppBar( - isKeyMapEnabled = isKeyMapEnabled, + name = nameDraft, + onNameChange = { nameDraft = it }, + isEditingName = state.isEditingName, + onEditNameClick = onEditNameClick, + onConfirmNameClick = { onConfirmNameClick(nameDraft.text) }, + isKeyMapEnabled = state.isEnabled, onKeyMapEnabledChange = onKeyMapEnabledChange, - onBackClick = onBackClick, - onDoneClick = onDoneClick, - showHelpButton = currentTab == ConfigKeyMapTab.TRIGGER || - currentTab == ConfigKeyMapTab.ACTIONS || - currentTab == ConfigKeyMapTab.CONSTRAINTS || - currentTab == ConfigKeyMapTab.OPTIONS, - onHelpClick = { - val url = when (currentTab) { - ConfigKeyMapTab.TRIGGER -> triggerHelpUrl - ConfigKeyMapTab.ACTIONS -> actionsHelpUrl - ConfigKeyMapTab.CONSTRAINTS -> constraintsHelpUrl - ConfigKeyMapTab.OPTIONS -> optionsHelpUrl - else -> return@ConfigKeyMapAppBar - } - - if (url.isNotEmpty()) { - uriHandler.openUriSafe(ctx, url) - } - }, + canUndo = state.undoRedo.canUndo, + canRedo = state.undoRedo.canRedo, + onUndoClick = onUndoClick, + onRedoClick = onRedoClick, + onBackClick = onBack, ) }, ) { innerPadding -> @@ -121,7 +126,6 @@ fun BaseConfigKeyMapScreen( val tabs = determineTabs(maxWidth, maxHeight) val isVerticalTwoScreen = maxWidth < 720.dp val pagerState = rememberPagerState(pageCount = { tabs.size }, initialPage = 0) - currentTab = tabs.getOrNull(pagerState.targetPage) Column(Modifier.fillMaxSize()) { if (tabs.size > 1) { @@ -137,7 +141,7 @@ fun BaseConfigKeyMapScreen( var finishedAnimation by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(showActionPulse) { + LaunchedEffect(state.showActionPulse) { var startedAnimation = false repeat(10) { @@ -147,7 +151,7 @@ fun BaseConfigKeyMapScreen( pagerState.targetPage == index && tab == ConfigKeyMapTab.ACTIONS - if (!showActionPulse || + if (!state.showActionPulse || finishedAnimation || isActionsTabSelected ) { @@ -303,69 +307,6 @@ fun BaseConfigKeyMapScreen( } } -@Composable -private fun ConfigKeyMapAppBar( - modifier: Modifier = Modifier, - isKeyMapEnabled: Boolean, - onKeyMapEnabledChange: (Boolean) -> Unit = {}, - showHelpButton: Boolean, - onHelpClick: () -> Unit, - onBackClick: () -> Unit, - onDoneClick: () -> Unit, -) { - BottomAppBar( - modifier = modifier, - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onDoneClick, - text = { Text(stringResource(R.string.button_done)) }, - icon = { - Icon(Icons.Rounded.Check, stringResource(R.string.button_done)) - }, - elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), - ) - }, - actions = { - IconButton(onClick = onBackClick) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.action_go_back)) - } - - Spacer(modifier = Modifier.width(8.dp)) - - val text = if (isKeyMapEnabled) { - stringResource(R.string.switch_enabled) - } else { - stringResource(R.string.switch_disabled) - } - - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Switch( - checked = isKeyMapEnabled, - onCheckedChange = onKeyMapEnabledChange, - ) - - Spacer(modifier = Modifier.weight(1f)) - - if (showHelpButton) { - IconButton(onClick = onHelpClick) { - Icon( - Icons.AutoMirrored.Rounded.HelpOutline, - stringResource(R.string.action_help), - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - }, - ) -} - @Composable private fun VerticalTwoScreens( modifier: Modifier = Modifier, @@ -544,7 +485,8 @@ private fun determineTabs(maxWidth: Dp, maxHeight: Dp): List { return when { maxWidth >= 800.dp && maxHeight >= 800.dp -> listOf(ConfigKeyMapTab.ALL) - (maxWidth >= 1000.dp && maxHeight >= 450.dp) || + (maxWidth >= 800.dp && maxHeight >= 450.dp) || + // Stacking on top of each other should require more height than when landscape (maxWidth >= 450.dp && maxHeight >= 1000.dp) -> listOf( ConfigKeyMapTab.TRIGGER_AND_ACTIONS, ConfigKeyMapTab.CONSTRAINTS_AND_OPTIONS, @@ -588,7 +530,7 @@ private fun SmallScreenPreview() { KeyMapperTheme { BaseConfigKeyMapScreen( modifier = Modifier.fillMaxSize(), - isKeyMapEnabled = false, + state = ConfigKeyMapScreenState(isEnabled = false), triggerScreen = {}, actionsScreen = {}, constraintsScreen = {}, @@ -603,7 +545,7 @@ private fun MediumScreenPreview() { KeyMapperTheme { BaseConfigKeyMapScreen( modifier = Modifier.fillMaxSize(), - isKeyMapEnabled = true, + state = ConfigKeyMapScreenState(isEnabled = true), triggerScreen = {}, actionsScreen = {}, constraintsScreen = {}, @@ -618,7 +560,7 @@ private fun MediumScreenLandscapePreview() { KeyMapperTheme { BaseConfigKeyMapScreen( modifier = Modifier.fillMaxSize(), - isKeyMapEnabled = true, + state = ConfigKeyMapScreenState(isEnabled = true), triggerScreen = {}, actionsScreen = {}, constraintsScreen = {}, @@ -633,7 +575,7 @@ private fun LargeScreenPreview() { KeyMapperTheme { BaseConfigKeyMapScreen( modifier = Modifier.fillMaxSize(), - isKeyMapEnabled = true, + state = ConfigKeyMapScreenState(isEnabled = true), triggerScreen = {}, actionsScreen = {}, constraintsScreen = {}, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapAppBar.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapAppBar.kt new file mode 100644 index 0000000000..5258df5a3a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapAppBar.kt @@ -0,0 +1,259 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Redo +import androidx.compose.material.icons.automirrored.rounded.Undo +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.EditableAppBarTextField +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConfigKeyMapAppBar( + modifier: Modifier = Modifier, + name: TextFieldValue, + onNameChange: (TextFieldValue) -> Unit, + isEditingName: Boolean, + onEditNameClick: () -> Unit, + onConfirmNameClick: () -> Unit, + isKeyMapEnabled: Boolean, + onKeyMapEnabledChange: (Boolean) -> Unit, + canUndo: Boolean, + canRedo: Boolean, + onUndoClick: () -> Unit, + onRedoClick: () -> Unit, + onBackClick: () -> Unit, +) { + BoxWithConstraints(modifier) { + val showUndoRedoInline = maxWidth >= 400.dp + + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + stringResource(R.string.action_go_back), + ) + } + + EditableAppBarTextField( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.config_key_map_name_placeholder), + isEditing = isEditingName, + onConfirmClick = onConfirmNameClick, + onEditClick = onEditNameClick, + confirmContentDescription = stringResource( + R.string.config_key_map_save_name, + ), + editContentDescription = stringResource(R.string.config_key_map_edit_name), + ) + + AnimatedVisibility(visible = !isEditingName) { + Row( + modifier = Modifier.align(Alignment.CenterVertically), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showUndoRedoInline) { + IconButton(onClick = onUndoClick, enabled = canUndo) { + Icon( + Icons.AutoMirrored.Rounded.Undo, + stringResource(R.string.action_undo), + ) + } + + IconButton(onClick = onRedoClick, enabled = canRedo) { + Icon( + Icons.AutoMirrored.Rounded.Redo, + stringResource(R.string.action_redo), + ) + } + + Spacer(Modifier.width(8.dp)) + } + + val enabledText = if (isKeyMapEnabled) { + stringResource(R.string.switch_enabled) + } else { + stringResource(R.string.switch_disabled) + } + + val tooltipText = if (isKeyMapEnabled) { + stringResource(R.string.config_key_map_enabled_tooltip) + } else { + stringResource(R.string.config_key_map_disabled_tooltip) + } + + val tooltipState = rememberTooltipState() + val coroutineScope = rememberCoroutineScope() + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + ), + tooltip = { PlainTooltip { Text(tooltipText) } }, + state = tooltipState, + ) { + Switch( + modifier = Modifier.semantics { + contentDescription = enabledText + }, + checked = isKeyMapEnabled, + onCheckedChange = { + onKeyMapEnabledChange(it) + coroutineScope.launch { tooltipState.show() } + }, + ) + } + + Spacer(Modifier.width(8.dp)) + + if (!showUndoRedoInline) { + UndoRedoMenu( + canUndo = canUndo, + canRedo = canRedo, + onUndoClick = onUndoClick, + onRedoClick = onRedoClick, + ) + } + } + } + }, + ) + } +} + +@Composable +private fun UndoRedoMenu( + canUndo: Boolean, + canRedo: Boolean, + onUndoClick: () -> Unit, + onRedoClick: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + IconButton(onClick = { expanded = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.home_app_bar_more), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 0.dp, y = 100.dp), + ) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Undo, contentDescription = null) }, + text = { Text(stringResource(R.string.action_undo)) }, + enabled = canUndo, + onClick = { + expanded = false + onUndoClick() + }, + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Redo, contentDescription = null) }, + text = { Text(stringResource(R.string.action_redo)) }, + enabled = canRedo, + onClick = { + expanded = false + onRedoClick() + }, + ) + } +} + +@Composable +private fun PreviewAppBar( + name: String = "", + isEditingName: Boolean = false, + isKeyMapEnabled: Boolean = true, + canUndo: Boolean = true, + canRedo: Boolean = true, +) { + KeyMapperTheme { + ConfigKeyMapAppBar( + name = TextFieldValue(name), + onNameChange = {}, + isEditingName = isEditingName, + onEditNameClick = {}, + onConfirmNameClick = {}, + isKeyMapEnabled = isKeyMapEnabled, + onKeyMapEnabledChange = {}, + canUndo = canUndo, + canRedo = canRedo, + onUndoClick = {}, + onRedoClick = {}, + onBackClick = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun UnnamedPreview() { + PreviewAppBar(name = "") +} + +@PreviewLightDark +@Composable +private fun EditingNamePreview() { + PreviewAppBar(name = "Volume up to skip song", isEditingName = true) +} + +@PreviewLightDark +@Composable +private fun EditingEmptyNamePreview() { + PreviewAppBar(name = "", isEditingName = true) +} + +@PreviewLightDark +@PreviewScreenSizes +@Composable +private fun NamedPreview() { + PreviewAppBar(name = "Volume up to skip song") +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt index a9eb84da5c..ec35bca862 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt @@ -3,12 +3,8 @@ package io.github.sds100.keymapper.base.keymaps import androidx.compose.material3.SnackbarHostState 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.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.sds100.keymapper.base.utils.ui.UnsavedChangesDialog @Composable fun ConfigKeyMapScreen( @@ -20,37 +16,22 @@ fun ConfigKeyMapScreen( constraintsScreen: @Composable () -> Unit, optionsScreen: @Composable () -> Unit, ) { - val isKeyMapEnabled by keyMapViewModel.isEnabled.collectAsStateWithLifecycle() - val showActionPulse by keyMapViewModel.showActionsTapTarget.collectAsStateWithLifecycle() - var showBackDialog by rememberSaveable { mutableStateOf(false) } - - if (showBackDialog) { - UnsavedChangesDialog( - onDismiss = { showBackDialog = false }, - onDiscardClick = { - showBackDialog = false - keyMapViewModel.onBackClick() - }, - ) - } + val state by keyMapViewModel.state.collectAsStateWithLifecycle() BaseConfigKeyMapScreen( modifier = modifier, - isKeyMapEnabled = isKeyMapEnabled, + state = state, + onEditNameClick = keyMapViewModel::onEditNameClick, + onConfirmNameClick = keyMapViewModel::onConfirmNameClick, + onCancelEditNameClick = keyMapViewModel::onCancelEditNameClick, onKeyMapEnabledChange = keyMapViewModel::onEnabledChanged, + onUndoClick = keyMapViewModel::onUndoClick, + onRedoClick = keyMapViewModel::onRedoClick, triggerScreen = triggerScreen, actionsScreen = actionsScreen, constraintsScreen = constraintsScreen, optionsScreen = optionsScreen, - onBackClick = { - if (keyMapViewModel.isKeyMapEdited) { - showBackDialog = true - } else { - keyMapViewModel.onBackClick() - } - }, - onDoneClick = keyMapViewModel::onDoneClick, + onBackClick = keyMapViewModel::onBackClick, snackbarHostState = snackbarHostState, - showActionPulse = showActionPulse, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreenState.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreenState.kt new file mode 100644 index 0000000000..9dc6cf3312 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreenState.kt @@ -0,0 +1,11 @@ +package io.github.sds100.keymapper.base.keymaps + +import io.github.sds100.keymapper.common.utils.UndoRedoState + +data class ConfigKeyMapScreenState( + val isEnabled: Boolean = true, + val showActionPulse: Boolean = false, + val name: String = "", + val isEditingName: Boolean = false, + val undoRedo: UndoRedoState = UndoRedoState(), +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt index c2509d11f1..24282d0d1a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt @@ -3,6 +3,8 @@ package io.github.sds100.keymapper.base.keymaps import android.database.sqlite.SQLiteConstraintException import android.os.Bundle import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.UndoRedo +import io.github.sds100.keymapper.common.utils.UndoRedoState import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.common.utils.ifIsData import io.github.sds100.keymapper.common.utils.mapData @@ -30,6 +32,7 @@ class ConfigKeyMapStateImpl @Inject constructor( private val keyMapRepository: KeyMapRepository, private val floatingButtonRepository: FloatingButtonRepository, ) : ConfigKeyMapState { + private var originalKeyMap: KeyMap? = null private val _keyMap: MutableStateFlow> = MutableStateFlow(State.Loading) @@ -37,6 +40,10 @@ class ConfigKeyMapStateImpl @Inject constructor( override val floatingButtonToUse: MutableStateFlow = MutableStateFlow(null) + private val undoRedo: UndoRedo = UndoRedo() + + override val undoRedoState: StateFlow = undoRedo.state + init { // Update button data in the key map whenever the floating buttons changes. coroutineScope.launch { @@ -53,15 +60,6 @@ class ConfigKeyMapStateImpl @Inject constructor( } } - /** - * Whether any changes were made to the key map. - */ - override val isEdited: Boolean - get() = when (val keyMap = keyMap.value) { - is State.Data -> originalKeyMap?.let { it != keyMap.data } ?: false - State.Loading -> false - } - override suspend fun loadKeyMap(uid: String) { _keyMap.update { State.Loading } val entity = keyMapRepository.get(uid) ?: return @@ -73,23 +71,29 @@ class ConfigKeyMapStateImpl @Inject constructor( val keyMap = KeyMapEntityMapper.fromEntity(entity, floatingButtons) _keyMap.update { State.Data(keyMap) } originalKeyMap = keyMap + undoRedo.reset(keyMap) } override fun loadNewKeyMap(groupUid: String?) { val keyMap = KeyMap(groupUid = groupUid) _keyMap.update { State.Data(keyMap) } originalKeyMap = keyMap + undoRedo.reset(keyMap) } // Useful for testing fun setKeyMap(keyMap: KeyMap) { _keyMap.update { State.Data(keyMap) } originalKeyMap = keyMap + undoRedo.reset(keyMap) } override fun save() { val keyMap = keyMap.value.dataOrNull() ?: return + // save() is called from several lifecycle points so skip redundant writes. + if (keyMap == originalKeyMap) return + if (keyMap.dbId == null) { val entity = KeyMapEntityMapper.toEntity(keyMap, 0) try { @@ -100,6 +104,8 @@ class ConfigKeyMapStateImpl @Inject constructor( } else { keyMapRepository.update(KeyMapEntityMapper.toEntity(keyMap, keyMap.dbId)) } + + originalKeyMap = keyMap } fun saveState(bundle: Bundle) { @@ -112,21 +118,40 @@ class ConfigKeyMapStateImpl @Inject constructor( if (bundle.containsKey("ConfigKeyMapState.key_map")) { val json = bundle.getString("ConfigKeyMapState.key_map") ?: return - _keyMap.update { State.Data(Json.decodeFromString(json)) } + val keyMap = Json.decodeFromString(json) + _keyMap.update { State.Data(keyMap) } + undoRedo.reset(keyMap) } } override fun update(block: (keyMap: KeyMap) -> KeyMap): State { - return _keyMap.updateAndGet { value -> value.mapData { block.invoke(it) } } + val old = _keyMap.value + val new = _keyMap.updateAndGet { value -> value.mapData { block.invoke(it) } } + + if (new is State.Data && new != old) { + undoRedo.record(new.data) + } + + return new + } + + override fun undo() { + undoRedo.undo()?.also { keyMap -> _keyMap.update { State.Data(keyMap) } } + } + + override fun redo() { + undoRedo.redo()?.also { keyMap -> _keyMap.update { State.Data(keyMap) } } } } interface ConfigKeyMapState { val keyMap: StateFlow> - val isEdited: Boolean + val undoRedoState: StateFlow fun update(block: (keyMap: KeyMap) -> KeyMap): State fun save() + fun undo() + fun redo() suspend fun loadKeyMap(uid: String) fun loadNewKeyMap(groupUid: String?) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt index 3a478e8f0f..9778c173b1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt @@ -11,12 +11,15 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -30,14 +33,15 @@ class ConfigKeyMapViewModel @Inject constructor( NavigationProvider by navigationProvider, DialogProvider by dialogProvider { - val isKeyMapEdited: Boolean - get() = configKeyMapState.isEdited + private val isEditingName = MutableStateFlow(false) - val isEnabled: StateFlow = configTrigger.keyMap + private val isEnabled: Flow = configTrigger.keyMap .map { state -> state.dataOrNull()?.isEnabled ?: true } - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - val showActionsTapTarget: StateFlow = + private val keyMapName: Flow = configKeyMapState.keyMap + .map { state -> state.dataOrNull()?.name ?: "" } + + private val showActionsTapTarget: Flow = combine( onboarding.showTapTarget(OnboardingTapTarget.CHOOSE_ACTION), configKeyMapState.keyMap.filterIsInstance>(), @@ -47,15 +51,16 @@ class ConfigKeyMapViewModel @Inject constructor( showTapTarget && keyMapState.data.trigger.keys.isNotEmpty() && keyMapState.data.actionList.isEmpty() - }.stateIn(viewModelScope, SharingStarted.Lazily, false) - - fun onDoneClick() { - configKeyMapState.save() - - viewModelScope.launch { - popBackStack() } - } + + val state: StateFlow = combine( + isEnabled, + showActionsTapTarget, + keyMapName, + isEditingName, + configKeyMapState.undoRedoState, + ::ConfigKeyMapScreenState, + ).stateIn(viewModelScope, SharingStarted.Eagerly, ConfigKeyMapScreenState()) fun loadNewKeyMap(groupUid: String?) { configKeyMapState.loadNewKeyMap(groupUid) @@ -68,12 +73,40 @@ class ConfigKeyMapViewModel @Inject constructor( } fun onBackClick() { + configKeyMapState.save() + viewModelScope.launch { popBackStack() } } + fun onEditNameClick() { + isEditingName.update { true } + } + + fun onConfirmNameClick(name: String) { + configKeyMapState.update { it.copy(name = name.trim()) } + isEditingName.update { false } + } + + fun onCancelEditNameClick() { + isEditingName.update { false } + } + + fun onUndoClick() { + configKeyMapState.undo() + } + + fun onRedoClick() { + configKeyMapState.redo() + } + fun onEnabledChanged(enabled: Boolean) { configTrigger.setEnabled(enabled) } + + override fun onCleared() { + configKeyMapState.save() + super.onCleared() + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt index 137be1786f..ef98399506 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt @@ -50,6 +50,7 @@ fun EnableByIntentScreen( label = stringResource(R.string.intent_screen_label_class), code = ENABLE_RECEIVER_CLASS, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) Text( @@ -62,18 +63,21 @@ fun EnableByIntentScreen( label = stringResource(R.string.intent_screen_label_action_enable), code = ACTION_ENABLE_KEY_MAP, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) CodeBlock( label = stringResource(R.string.intent_screen_label_action_disable), code = ACTION_DISABLE_KEY_MAP, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) CodeBlock( label = stringResource(R.string.intent_screen_label_action_toggle), code = ACTION_TOGGLE_KEY_MAP, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) Text( @@ -86,6 +90,7 @@ fun EnableByIntentScreen( label = stringResource(R.string.intent_screen_label_extra_name), code = EXTRA_KEYMAP_UID, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) CodeBlock( @@ -99,7 +104,8 @@ fun EnableByIntentScreen( packageName = packageName, receiverClass = ENABLE_RECEIVER_CLASS, action = ACTION_TOGGLE_KEY_MAP, - keyMapUid = keyMapUid, + extraName = EXTRA_KEYMAP_UID, + extraValue = keyMapUid, ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableGroupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableGroupUseCase.kt new file mode 100644 index 0000000000..519efcaaea --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableGroupUseCase.kt @@ -0,0 +1,28 @@ +package io.github.sds100.keymapper.base.keymaps + +import io.github.sds100.keymapper.data.repositories.KeyMapRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EnableGroupUseCaseImpl @Inject constructor(private val keyMapRepository: KeyMapRepository) : + EnableGroupUseCase { + + override fun enable(groupUid: String) { + keyMapRepository.enableByGroup(groupUid) + } + + override fun toggle(groupUid: String) { + keyMapRepository.toggleByGroup(groupUid) + } + + override fun disable(groupUid: String) { + keyMapRepository.disableByGroup(groupUid) + } +} + +interface EnableGroupUseCase { + fun enable(groupUid: String) + fun toggle(groupUid: String) + fun disable(groupUid: String) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt index 6df94ecb41..9f9c94c887 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt @@ -120,8 +120,14 @@ internal fun buildAdbCommand( packageName: String, receiverClass: String, action: String, - keyMapUid: String, + extraName: String? = null, + extraValue: String? = null, ): String { - return "adb shell am broadcast -n $packageName/$receiverClass " + - "-a $action --es $EXTRA_KEYMAP_UID $keyMapUid" + val extraArg = if (extraName != null && extraValue != null) { + " --es $extraName $extraValue" + } else { + "" + } + + return "adb shell am broadcast -n $packageName/$receiverClass -a $action$extraArg" } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt index 33e30c37dc..00c5396e3a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt @@ -28,6 +28,7 @@ data class KeyMap( val constraintState: ConstraintState = ConstraintState(), val isEnabled: Boolean = true, val groupUid: String? = null, + val name: String = "", ) { val showToast: Boolean @@ -139,6 +140,7 @@ object KeyMapEntityMapper { constraintState = constraintState, isEnabled = entity.isEnabled, groupUid = entity.groupUid, + name = entity.name ?: "", ) } @@ -157,6 +159,7 @@ object KeyMapEntityMapper { isEnabled = keyMap.isEnabled, uid = keyMap.uid, groupUid = keyMap.groupUid, + name = keyMap.name.ifBlank { null }, ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt index b1bf1f5e7e..47c1382a66 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt @@ -137,9 +137,9 @@ private fun Loaded( }, onButtonClick = callback::onOpenExpertModeSettings, ) - } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) + } OptionsHeaderRow( icon = Icons.Outlined.Info, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseResumeByIntentScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseResumeByIntentScreen.kt new file mode 100644 index 0000000000..0b35cc7ad7 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseResumeByIntentScreen.kt @@ -0,0 +1,88 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 io.github.sds100.keymapper.base.IntentApi +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock + +@Composable +fun PauseResumeByIntentScreen(modifier: Modifier = Modifier, onBackClick: () -> Unit) { + val packageName = LocalContext.current.packageName + val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label) + + IntentScreen( + modifier = modifier, + title = stringResource(R.string.title_pref_pause_resume_by_intent), + text = stringResource(R.string.intent_screen_pause_resume_message), + onBackClick = onBackClick, + ) { + Text( + text = stringResource(R.string.intent_screen_target_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_package), + code = packageName, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_class), + code = IntentApi.PAUSE_RECEIVER_CLASS, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + Text( + text = stringResource(R.string.intent_screen_action_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_tab_pause), + code = IntentApi.ACTION_PAUSE_MAPPINGS, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_tab_resume), + code = IntentApi.ACTION_RESUME_MAPPINGS, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_tab_toggle), + code = IntentApi.ACTION_TOGGLE_MAPPINGS, + clipboardLabel = clipboardLabel, + scrollToEnd = true, + ) + + AdbSection( + command = buildAdbCommand( + packageName = packageName, + receiverClass = IntentApi.PAUSE_RECEIVER_CLASS, + action = IntentApi.ACTION_TOGGLE_MAPPINGS, + ), + ) + } +} + +@Preview(heightDp = 1000) +@Composable +private fun PauseResumeByIntentScreenPreview() { + KeyMapperTheme { + PauseResumeByIntentScreen(onBackClick = {}) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt index 0fd3092c8a..2c25789ce4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt @@ -44,18 +44,21 @@ fun TriggerByIntentScreen( label = stringResource(R.string.intent_screen_label_class), code = IntentApi.TRIGGER_RECEIVER_CLASS, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) CodeBlock( label = stringResource(R.string.intent_screen_label_action), code = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) CodeBlock( label = stringResource(R.string.intent_screen_label_extra_name), code = EXTRA_KEYMAP_UID, clipboardLabel = clipboardLabel, + scrollToEnd = true, ) CodeBlock( @@ -69,7 +72,8 @@ fun TriggerByIntentScreen( packageName = packageName, receiverClass = IntentApi.TRIGGER_RECEIVER_CLASS, action = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, - keyMapUid = keyMapUid, + extraName = EXTRA_KEYMAP_UID, + extraValue = keyMapUid, ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt index 67a554b854..994e444faf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt @@ -1,32 +1,18 @@ package io.github.sds100.keymapper.base.keymaps -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectMode import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectPickerContent import io.github.sds100.keymapper.base.vibration.VibrateConfigState @@ -70,69 +56,25 @@ private fun TriggerVibrationBottomSheet( onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit = {}, onDoneClick: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() - - ModalBottomSheet( - onDismissRequest = onDismissRequest, + KMBottomSheet( + title = stringResource(R.string.flag_vibrate), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = onDoneClick, sheetState = sheetState, - dragHandle = null, + onDismissRequest = onDismissRequest, ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.flag_vibrate), - style = MaterialTheme.typography.headlineMedium, - ) - - VibrationEffectPickerContent( - modifier = Modifier.fillMaxWidth(), - mode = state.mode, - onModeChange = onModeChange, - durationMs = state.durationMs, - onDurationChange = onDurationChange, - defaultDurationMs = state.defaultDurationMs, - predefinedType = state.predefinedType, - onPredefinedTypeChange = onPredefinedTypeChange, - isPredefinedSupported = state.isPredefinedSupported, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = onDoneClick, - ) { - Text(stringResource(R.string.pos_done)) - } - } - } + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = state.mode, + onModeChange = onModeChange, + durationMs = state.durationMs, + onDurationChange = onDurationChange, + defaultDurationMs = state.defaultDurationMs, + predefinedType = state.predefinedType, + onPredefinedTypeChange = onPredefinedTypeChange, + isPredefinedSupported = state.isPredefinedSupported, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt index c916a43477..5a25a87bee 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt @@ -3,10 +3,8 @@ package io.github.sds100.keymapper.base.purchasing import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow interface PurchasingManager { - val onCompleteProductPurchase: MutableSharedFlow val state: Flow>> suspend fun launchPurchasingFlow( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt index 5d79cd6838..d2466a9c01 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt @@ -24,11 +24,12 @@ import androidx.compose.material.icons.outlined.Android import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.FindInPage import androidx.compose.material.icons.outlined.Gamepad +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.OfflineBolt import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Construction import androidx.compose.material.icons.rounded.Devices -import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material.icons.rounded.PauseCircleOutline import androidx.compose.material.icons.rounded.PlayCircleOutline import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Vibration @@ -70,6 +71,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose import io.github.sds100.keymapper.base.utils.ui.compose.icons.FolderManaged import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyboardPreviousLanguage import io.github.sds100.keymapper.base.utils.ui.compose.icons.WandStars import io.github.sds100.keymapper.common.utils.BuildUtils import io.github.sds100.keymapper.system.files.FileUtils @@ -148,6 +150,8 @@ fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) onPauseResumeNotificationClick = viewModel::onPauseResumeNotificationClick, onDefaultOptionsClick = viewModel::onDefaultOptionsClick, onExpertModeClick = viewModel::onExpertModeClick, + onPauseResumeByIntentClick = viewModel::onPauseResumeByIntentClick, + onEnableDisableGroupsByIntentClick = viewModel::onEnableDisableGroupsByIntentClick, onAutomaticChangeImeClick = viewModel::onAutomaticChangeImeClick, onForceVibrateToggled = viewModel::onForceVibrateToggled, onLoggingToggled = viewModel::onLoggingToggled, @@ -231,6 +235,8 @@ private fun Content( onDefaultOptionsClick: () -> Unit = { }, onAutomaticBackupClick: () -> Unit = { }, onExpertModeClick: () -> Unit = { }, + onPauseResumeByIntentClick: () -> Unit = { }, + onEnableDisableGroupsByIntentClick: () -> Unit = { }, onAutomaticChangeImeClick: () -> Unit = { }, onForceVibrateToggled: (Boolean) -> Unit = { }, onLoggingToggled: (Boolean) -> Unit = { }, @@ -336,8 +342,8 @@ private fun Content( OptionsHeaderRow( modifier = Modifier.fillMaxWidth(), - icon = Icons.Rounded.Construction, - text = stringResource(R.string.settings_section_power_user_title), + icon = Icons.Outlined.Keyboard, + text = stringResource(R.string.settings_section_onscreen_keyboard_title), ) KeyEventActionMethodRow( @@ -348,13 +354,6 @@ private fun Content( onSelected = onKeyEventActionMethodSelected, ) - OptionPageButton( - title = stringResource(R.string.title_pref_expert_mode), - text = stringResource(R.string.summary_pref_expert_mode), - icon = Icons.Outlined.OfflineBolt, - onClick = onExpertModeClick, - ) - OptionPageButton( title = stringResource(R.string.title_pref_automatically_change_ime), text = if (isAutoSwitchImeSupported) { @@ -365,11 +364,38 @@ private fun Content( BuildUtils.getSdkVersionName(Build.VERSION_CODES.R), ) }, - icon = Icons.Rounded.Keyboard, + icon = KeyMapperIcons.KeyboardPreviousLanguage, onClick = onAutomaticChangeImeClick, enabled = isAutoSwitchImeSupported, ) + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = Icons.Rounded.Construction, + text = stringResource(R.string.settings_section_power_user_title), + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_expert_mode), + text = stringResource(R.string.summary_pref_expert_mode), + icon = Icons.Outlined.OfflineBolt, + onClick = onExpertModeClick, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_pause_resume_by_intent), + text = stringResource(R.string.summary_pref_pause_resume_by_intent), + icon = Icons.Rounded.PauseCircleOutline, + onClick = onPauseResumeByIntentClick, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_enable_disable_groups_by_intent), + text = stringResource(R.string.summary_pref_enable_disable_groups_by_intent), + icon = KeyMapperIcons.FolderManaged, + onClick = onEnableDisableGroupsByIntentClick, + ) + OptionsHeaderRow( modifier = Modifier.fillMaxWidth(), icon = Icons.Rounded.Code, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt index 57dd804b14..568da791b8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt @@ -182,6 +182,18 @@ class SettingsViewModel @Inject constructor( } } + fun onPauseResumeByIntentClick() { + viewModelScope.launch { + navigate("pause_resume_by_intent", NavDestination.PauseResumeByIntent) + } + } + + fun onEnableDisableGroupsByIntentClick() { + viewModelScope.launch { + navigate("enable_group_by_intent", NavDestination.EnableGroupByIntent) + } + } + fun onAutomaticChangeImeClick() { viewModelScope.launch { navigate("automatic_change_ime", NavDestination.AutomaticChangeImeSettings) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt index c095902c40..74f164b7a4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt @@ -7,7 +7,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.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable @@ -35,7 +34,6 @@ import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.HorizontalRule import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -198,7 +196,7 @@ private fun SortBottomSheetContent( modifier = Modifier.align(Alignment.Center), text = stringResource(R.string.dialog_message_sort_sort_by), textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleLarge, ) TextButton( @@ -243,13 +241,6 @@ private fun SortBottomSheetContent( ) { OutlinedButton( onClick = onCancel, - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.error, - ), ) { Text(stringResource(R.string.neg_cancel)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt index 2d7218b399..1ce6f49fd5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt @@ -159,6 +159,8 @@ class KeyMapConstraintsComparator( ) ConstraintData.NotificationPanelShowing -> Success("") + + is ConstraintData.Variable -> Success(constraint.data.name) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt index 027cba0120..51240d1d39 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt @@ -1,6 +1,8 @@ package io.github.sds100.keymapper.base.system.accessibility +import android.accessibilityservice.AccessibilityService import android.os.Build +import android.view.Display import android.view.accessibility.AccessibilityNodeInfo /** @@ -10,6 +12,14 @@ import android.view.accessibility.AccessibilityNodeInfo */ private const val MAX_ACTION_TARGET_DEPTH = 10 +fun AccessibilityService.getActiveDisplayIdExt(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + rootInActiveWindow?.window?.displayId ?: Display.DEFAULT_DISPLAY + } else { + Display.DEFAULT_DISPLAY + } +} + /** * @return The node to find. Returns null if the node doesn't match the predicate */ diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt index 88ce39f6b5..f9a436e41f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt @@ -395,6 +395,9 @@ abstract class BaseAccessibilityService : strokeDescription.let { val gestureDescription = GestureDescription.Builder().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setDisplayId(getActiveDisplayId()) + } addStroke(it) }.build() @@ -432,6 +435,10 @@ abstract class BaseAccessibilityService : val gestureBuilder = GestureDescription.Builder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + gestureBuilder.setDisplayId(getActiveDisplayId()) + } + if (fingerCount == 1) { val p = Path() p.moveTo(pStart.x.toFloat(), pStart.y.toFloat()) @@ -482,7 +489,7 @@ abstract class BaseAccessibilityService : angle, ) - // create a path for each finger, move the the coordinates on the perpendicular line and draw it to the end coordinates of the perpendicular line of the end swipe point + // create a path for each finger, move the coordinates on the perpendicular line and draw it to the end coordinates of the perpendicular line of the end swipe point val p = Path() p.moveTo( startFingerCoordinateWithOffset.x.toFloat(), @@ -523,6 +530,11 @@ abstract class BaseAccessibilityService : } val gestureBuilder = GestureDescription.Builder() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + gestureBuilder.setDisplayId(getActiveDisplayId()) + } + val distributedPoints: List = MathUtils.distributePointsOnCircle(Point(x, y), distance.toFloat() / 2, fingerCount) @@ -635,4 +647,8 @@ abstract class BaseAccessibilityService : override fun performTalkBackGesture(gesture: TalkBackGestureType): KMResult<*> { return TalkbackGesturePerformer.performTalkBackGesture(this, gesture, gestureHandler) } + + override fun getActiveDisplayId(): Int { + return getActiveDisplayIdExt() + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt index eae36db432..02106d7adf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt @@ -50,6 +50,8 @@ interface IAccessibilityService : SwitchImeInterface { val activeWindowPackage: Flow val activeWindowPackageNames: List + fun getActiveDisplayId(): Int + fun hideKeyboard() fun showKeyboard() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt index 34f80b2666..0ea0ce98b6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt @@ -17,5 +17,6 @@ data class KeyMapListItemModel(val isSelected: Boolean, val content: Content) { val options: List, val isEnabled: Boolean, val hasError: Boolean, + val name: String = "", ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index 187ffb2137..44d423cccb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt @@ -156,6 +156,9 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { R.string.error_system_feature_telephony_unsupported, ) + PackageManager.FEATURE_SENSOR_HINGE_ANGLE -> + resourceProvider.getString(R.string.error_system_feature_hinge_unsupported) + else -> throw Exception( "Don't know how to get error message for this system feature ${this.feature}", @@ -217,6 +220,9 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { is KMError.NumberTooBig -> resourceProvider.getString(R.string.error_number_too_big, max) + is KMError.NumberOverflow -> + resourceProvider.getString(R.string.error_number_overflow) + is KMError.EmptyText -> resourceProvider.getString(R.string.error_cant_be_empty) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index ba50beebc0..27a6187b67 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -51,6 +51,8 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_TRIGGER_BY_INTENT = "trigger_by_intent" const val ID_ENABLE_BY_INTENT = "enable_by_intent" const val ID_CONFIG_NOTIFICATION_CONSTRAINT = "config_notification_constraint" + const val ID_PAUSE_RESUME_BY_INTENT = "pause_resume_by_intent" + const val ID_ENABLE_GROUP_BY_INTENT = "enable_group_by_intent" } @Serializable @@ -129,7 +131,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data object ConfigNotificationConstraint : + data class ConfigNotificationConstraint(val oldJson: String? = null) : NavDestination(isCompose = true) { override val id: String = ID_CONFIG_NOTIFICATION_CONSTRAINT } @@ -243,4 +245,14 @@ abstract class NavDestination(val isCompose: Boolean = false) { data class EnableByIntent(val keyMapUid: String) : NavDestination(isCompose = true) { override val id: String = ID_ENABLE_BY_INTENT } + + @Serializable + data object PauseResumeByIntent : NavDestination(isCompose = true) { + override val id: String = ID_PAUSE_RESUME_BY_INTENT + } + + @Serializable + data object EnableGroupByIntent : NavDestination(isCompose = true) { + override val id: String = ID_ENABLE_GROUP_BY_INTENT + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt index fa0658fc46..69f66b7e01 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -57,6 +58,10 @@ fun CodeBlock( label: String? = null, maxLineLength: Int = CodeBlockDefaults.MAX_LINE_LENGTH, maxHeight: Dp = CodeBlockDefaults.MaxHeight, + /** + * Whether to scroll horizontally to the end by default. + */ + scrollToEnd: Boolean = false, ) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() @@ -92,12 +97,20 @@ fun CodeBlock( color = MaterialTheme.colorScheme.surfaceContainerHighest, ) { Row(verticalAlignment = Alignment.CenterVertically) { + val horizontalScrollState = rememberScrollState() + + if (scrollToEnd) { + LaunchedEffect(Unit) { + horizontalScrollState.scrollTo(horizontalScrollState.maxValue) + } + } + SelectionContainer( modifier = Modifier .weight(1f) .heightIn(max = maxHeight) .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()), + .horizontalScroll(horizontalScrollState), ) { Text( modifier = Modifier diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt index fb6cb0f2ad..4e68f0aaaa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt @@ -180,7 +180,6 @@ fun CustomDialogContent( Text( modifier = Modifier .align(Alignment.Start) - .fillMaxWidth() .padding(start = 24.dp, end = 24.dp), text = title, style = MaterialTheme.typography.headlineSmall, @@ -194,7 +193,6 @@ fun CustomDialogContent( Text( modifier = Modifier .align(Alignment.Start) - .fillMaxWidth() .padding(start = 24.dp, end = 24.dp), text = text, style = MaterialTheme.typography.bodyMedium, @@ -211,7 +209,6 @@ fun CustomDialogContent( Row( modifier = Modifier - .fillMaxWidth() .align(Alignment.End) .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.End), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/EditableAppBarTextField.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/EditableAppBarTextField.kt new file mode 100644 index 0000000000..5b517356df --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/EditableAppBarTextField.kt @@ -0,0 +1,161 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +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.width +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.rounded.Done +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +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.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun EditableAppBarTextField( + modifier: Modifier = Modifier, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit = {}, + placeholder: String, + isEditing: Boolean, + onConfirmClick: () -> Unit, + onEditClick: () -> Unit = {}, + error: String? = null, + confirmContentDescription: String, + editContentDescription: String, +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isEditing) { + focusRequester.requestFocus() + } + + AnimatedContent(modifier = modifier, targetState = isEditing) { isEditing -> + Row( + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + val interactionSource = remember { MutableInteractionSource() } + + // Use a custom text field so the content padding can be customised. + BasicTextField( + modifier = Modifier + .focusRequester(focusRequester) + .height(IntrinsicSize.Max) + .then( + if (isEditing) { + Modifier.weight(1f) + } else { + Modifier.weight(1f, fill = false) + }, + ), + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.titleLarge.copy( + color = LocalContentColor.current, + ), + enabled = isEditing, + keyboardActions = KeyboardActions(onDone = { onConfirmClick() }), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + showKeyboardOnFocus = true, + ), + singleLine = true, + maxLines = 1, + interactionSource = interactionSource, + ) { innerTextField -> + @OptIn(ExperimentalMaterial3Api::class) + OutlinedTextFieldDefaults.DecorationBox( + value = value.text, + placeholder = { + Text( + placeholder, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + color = OutlinedTextFieldDefaults.colors().disabledPlaceholderColor, + overflow = TextOverflow.Clip, + softWrap = false, + ) + }, + innerTextField = { + Box( + Modifier + .width(IntrinsicSize.Min) + .height(48.dp), + contentAlignment = Alignment.CenterStart, + ) { innerTextField() } + }, + singleLine = true, + colors = if (isEditing) { + OutlinedTextFieldDefaults.colors() + } else { + OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + disabledTextColor = LocalContentColor.current, + ) + }, + isError = error != null, + enabled = isEditing, + supportingText = if (error == null) { + null + } else { + { Text(error, maxLines = 1) } + }, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel( + top = 0.dp, + bottom = 0.dp, + end = 4.dp, + start = 8.dp, + ), + ) + } + + if (isEditing) { + IconButton(onClick = onConfirmClick) { + Icon( + Icons.Rounded.Done, + contentDescription = confirmContentDescription, + ) + } + } else { + IconButton(onClick = onEditClick) { + Icon( + Icons.Rounded.Edit, + contentDescription = editContentDescription, + ) + } + } + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt new file mode 100644 index 0000000000..e58ec6b5f2 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt @@ -0,0 +1,146 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.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.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices.TABLET +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KMBottomSheet( + title: String, + posButtonText: String, + onPosButtonClick: () -> Unit, + sheetState: SheetState, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + posButtonEnabled: Boolean = true, + negButtonText: String? = null, + onNegButtonClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = modifier + .widthIn(max = 450.dp) + // When the bottom sheet fills the whole screen, scrolling pushes the top + // underneath the status bar, but the content stays below the status bar. This gives + // it an ugly forehead so make sure the bottom sheet never goes under the status bar. + .statusBarsPadding(), + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + // Disable gestures so when scrolling down, the user doesn't accidentally dismiss the + // bottom sheet. Also, to disable the bottom sheet (not scrolling container) springing + // up when over-scrolling. + sheetGesturesEnabled = false, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = title, + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(Modifier.height(16.dp)) + + content() + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (negButtonText != null) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + if (onNegButtonClick != null) { + onNegButtonClick() + } else { + scope.launch { + sheetState.hide() + onDismissRequest() + } + } + }, + ) { + Text(negButtonText) + } + + Spacer(modifier = Modifier.width(16.dp)) + } + + Button( + modifier = Modifier.weight(1f), + enabled = posButtonEnabled, + onClick = onPosButtonClick, + ) { + Text(posButtonText) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true, device = TABLET) +@Composable +private fun KMBottomSheetPreview() { + KeyMapperTheme { + KMBottomSheet( + title = "Title", + negButtonText = "Cancel", + posButtonText = "Done", + onPosButtonClick = {}, + onDismissRequest = {}, + sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ), + ) { + Text("Content") + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ShrinkWrapText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ShrinkWrapText.kt new file mode 100644 index 0000000000..08d126ae3a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ShrinkWrapText.kt @@ -0,0 +1,69 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +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.base.compose.KeyMapperTheme +import kotlin.math.ceil + +/** + * A [Text] that is only as wide as its longest line. A normal [Text] that wraps takes up the + * whole max width even if its lines are shorter than that. + */ +@Composable +fun ShrinkWrapText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, +) { + val textMeasurer = rememberTextMeasurer() + + Text( + modifier = modifier.layout { measurable, constraints -> + val result = textMeasurer.measure( + text = text, + style = style, + overflow = overflow, + maxLines = maxLines, + constraints = constraints, + ) + // Round up so a sub-pixel shortfall doesn't make the last word wrap. + val widest = (0 until result.lineCount).maxOf { + ceil(result.getLineRight(it) - result.getLineLeft(it)).toInt() + } + val placeable = measurable.measure( + constraints.copy( + maxWidth = widest.coerceIn(constraints.minWidth, constraints.maxWidth), + ), + ) + + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + }, + text = text, + style = style, + maxLines = maxLines, + overflow = overflow, + ) +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + Column(Modifier.width(150.dp)) { + ShrinkWrapText(text = "Short") + ShrinkWrapText(text = "Flashlight is on (very very long text)", maxLines = 2) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/DualScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/DualScreen.kt new file mode 100644 index 0000000000..716c2ad75c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/DualScreen.kt @@ -0,0 +1,67 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.DualScreen: ImageVector + get() { + if (_DualScreen != null) { + return _DualScreen!! + } + _DualScreen = ImageVector.Builder( + name = "DualScreen", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveToRelative(240f, 704f) + lineToRelative(240f, 96f) + verticalLineToRelative(-544f) + lineToRelative(-240f, -96f) + verticalLineToRelative(544f) + close() + moveTo(210f, 778f) + quadToRelative(-23f, -9f, -36.5f, -29f) + reflectiveQuadTo(160f, 704f) + verticalLineToRelative(-544f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(240f, 80f) + lineToRelative(268f, 101f) + quadToRelative(23f, 9f, 37.5f, 29.5f) + reflectiveQuadTo(560f, 256f) + verticalLineToRelative(544f) + quadToRelative(0f, 43f, -35f, 66.5f) + reflectiveQuadTo(450f, 874f) + lineToRelative(-240f, -96f) + close() + moveTo(480f, 760f) + verticalLineToRelative(-80f) + horizontalLineToRelative(240f) + verticalLineToRelative(-520f) + lineTo(240f, 160f) + verticalLineToRelative(-80f) + horizontalLineToRelative(480f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(800f, 160f) + verticalLineToRelative(520f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(720f, 760f) + lineTo(480f, 760f) + close() + moveTo(240f, 704f) + verticalLineToRelative(-544f) + verticalLineToRelative(544f) + close() + } + }.build() + + return _DualScreen!! + } + +@Suppress("ObjectPropertyName") +private var _DualScreen: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Equal.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Equal.kt new file mode 100644 index 0000000000..151fb9ccec --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Equal.kt @@ -0,0 +1,53 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.Equal: ImageVector + get() { + if (_Equal != null) { + return _Equal!! + } + _Equal = ImageVector.Builder( + name = "Equal", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(220f, 680f) + quadToRelative(-25f, 0f, -42.5f, -17.5f) + reflectiveQuadTo(160f, 620f) + quadToRelative(0f, -25f, 17.5f, -42.5f) + reflectiveQuadTo(220f, 560f) + horizontalLineToRelative(520f) + quadToRelative(25f, 0f, 42.5f, 17.5f) + reflectiveQuadTo(800f, 620f) + quadToRelative(0f, 25f, -17.5f, 42.5f) + reflectiveQuadTo(740f, 680f) + lineTo(220f, 680f) + close() + moveTo(220f, 400f) + quadToRelative(-25f, 0f, -42.5f, -17.5f) + reflectiveQuadTo(160f, 340f) + quadToRelative(0f, -25f, 17.5f, -42.5f) + reflectiveQuadTo(220f, 280f) + horizontalLineToRelative(520f) + quadToRelative(25f, 0f, 42.5f, 17.5f) + reflectiveQuadTo(800f, 340f) + quadToRelative(0f, 25f, -17.5f, 42.5f) + reflectiveQuadTo(740f, 400f) + lineTo(220f, 400f) + close() + } + }.build() + + return _Equal!! + } + +@Suppress("ObjectPropertyName") +private var _Equal: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/KeyboardPreviousLanguage.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/KeyboardPreviousLanguage.kt new file mode 100644 index 0000000000..bff6125d06 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/KeyboardPreviousLanguage.kt @@ -0,0 +1,136 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.KeyboardPreviousLanguage: ImageVector + get() { + if (_KeyboardPreviousLanguage != null) { + return _KeyboardPreviousLanguage!! + } + _KeyboardPreviousLanguage = ImageVector.Builder( + name = "KeyboardPreviousLanguage", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(160f, 880f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(80f, 800f) + verticalLineToRelative(-400f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(160f, 320f) + horizontalLineToRelative(640f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(880f, 400f) + verticalLineToRelative(400f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(800f, 880f) + lineTo(160f, 880f) + close() + moveTo(160f, 800f) + horizontalLineToRelative(640f) + verticalLineToRelative(-400f) + lineTo(160f, 400f) + verticalLineToRelative(400f) + close() + moveTo(320f, 760f) + horizontalLineToRelative(320f) + verticalLineToRelative(-80f) + lineTo(320f, 680f) + verticalLineToRelative(80f) + close() + moveTo(200f, 640f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(320f, 640f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(440f, 640f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(560f, 640f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(680f, 640f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(200f, 520f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(320f, 520f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(440f, 520f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(560f, 520f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(680f, 520f) + horizontalLineToRelative(80f) + verticalLineToRelative(-80f) + horizontalLineToRelative(-80f) + verticalLineToRelative(80f) + close() + moveTo(160f, 800f) + verticalLineToRelative(-400f) + verticalLineToRelative(400f) + close() + moveTo(240f, 240f) + verticalLineToRelative(-200f) + horizontalLineToRelative(80f) + verticalLineToRelative(61f) + quadToRelative(32f, -29f, 73f, -45f) + reflectiveQuadToRelative(87f, -16f) + quadToRelative(88f, 0f, 155f, 56.5f) + reflectiveQuadTo(716f, 240f) + horizontalLineToRelative(-82f) + quadToRelative(-14f, -53f, -56.5f, -86.5f) + reflectiveQuadTo(480f, 120f) + quadToRelative(-30f, 0f, -57f, 10.5f) + reflectiveQuadTo(375f, 160f) + horizontalLineToRelative(65f) + verticalLineToRelative(80f) + lineTo(240f, 240f) + close() + } + }.build() + + return _KeyboardPreviousLanguage!! + } + +@Suppress("ObjectPropertyName") +private var _KeyboardPreviousLanguage: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Mobile.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Mobile.kt new file mode 100644 index 0000000000..e9a4060aff --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/Mobile.kt @@ -0,0 +1,69 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.Mobile: ImageVector + get() { + if (_Mobile != null) { + return _Mobile!! + } + _Mobile = ImageVector.Builder( + name = "Mobile", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(280f, 920f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(200f, 840f) + verticalLineToRelative(-720f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(280f, 40f) + horizontalLineToRelative(400f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(760f, 120f) + verticalLineToRelative(124f) + quadToRelative(18f, 7f, 29f, 22f) + reflectiveQuadToRelative(11f, 34f) + verticalLineToRelative(80f) + quadToRelative(0f, 19f, -11f, 34f) + reflectiveQuadToRelative(-29f, 22f) + verticalLineToRelative(404f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(680f, 920f) + lineTo(280f, 920f) + close() + moveTo(280f, 840f) + horizontalLineToRelative(400f) + verticalLineToRelative(-720f) + lineTo(280f, 120f) + verticalLineToRelative(720f) + close() + moveTo(280f, 840f) + verticalLineToRelative(-720f) + verticalLineToRelative(720f) + close() + moveTo(508.5f, 228.5f) + quadTo(520f, 217f, 520f, 200f) + reflectiveQuadToRelative(-11.5f, -28.5f) + quadTo(497f, 160f, 480f, 160f) + reflectiveQuadToRelative(-28.5f, 11.5f) + quadTo(440f, 183f, 440f, 200f) + reflectiveQuadToRelative(11.5f, 28.5f) + quadTo(463f, 240f, 480f, 240f) + reflectiveQuadToRelative(28.5f, -11.5f) + close() + } + }.build() + + return _Mobile!! + } + +@Suppress("ObjectPropertyName") +private var _Mobile: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableComparison.kt b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableComparison.kt new file mode 100644 index 0000000000..2ba6a54151 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableComparison.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.base.variables + +import kotlinx.serialization.Serializable + +@Serializable +enum class VariableComparison { + EQUALS, + GREATER_THAN, + LESS_THAN, + ; + + fun next(): VariableComparison { + return entries[(ordinal + 1) % entries.size] + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableConfigContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableConfigContent.kt new file mode 100644 index 0000000000..bb0ad11886 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableConfigContent.kt @@ -0,0 +1,283 @@ +package io.github.sds100.keymapper.base.variables + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +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.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun VariableConfigContent( + modifier: Modifier = Modifier, + name: String, + value: String, + suggestions: List, + nameError: String? = null, + onNameChange: (String) -> Unit = {}, + onValueChange: (String) -> Unit = {}, + onSuggestionClick: (String) -> Unit = {}, + onResetClick: (String) -> Unit = {}, + onResetAllClick: () -> Unit = {}, + onCycleOperationClick: () -> Unit = {}, + operationIcon: (@Composable () -> Unit)? = null, + operationLabel: String = "", +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + modifier = Modifier.widthIn(min = 128.dp), + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.variable_name_label)) }, + singleLine = true, + isError = nameError != null, + supportingText = if (nameError == null) { + null + } else { + { Text(text = nameError, color = MaterialTheme.colorScheme.error) } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + ), + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + if (operationIcon != null) { + // One button that cycles through the operations rather than a row of them. + val tooltipState = rememberTooltipState() + val scope = rememberCoroutineScope() + + TooltipBox( + // Material text fields expose no baseline, so centre the button on the 56dp + // field's text line instead. Top alignment keeps it put when error text shows. + modifier = Modifier.padding(top = 12.dp), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + ), + tooltip = { PlainTooltip { Text(operationLabel) } }, + state = tooltipState, + ) { + FilledTonalIconButton( + onClick = { + onCycleOperationClick() + // Show the newly selected operation after each cycle. + scope.launch { tooltipState.show() } + }, + ) { + operationIcon() + } + } + } + + OutlinedTextField( + modifier = Modifier.widthIn(min = 96.dp), + value = value, + onValueChange = onValueChange, + label = { Text(stringResource(R.string.variable_value_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + + if (suggestions.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.variable_suggestions), + style = MaterialTheme.typography.titleMedium, + ) + + TextButton(onClick = onResetAllClick) { + Text(stringResource(R.string.variable_reset_all)) + } + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + for (suggestion in suggestions) { + InputChip( + selected = suggestion.name == name, + onClick = { onSuggestionClick(suggestion.name) }, + label = { + Text( + text = stringResource( + R.string.variable_chip_label, + suggestion.name, + suggestion.value.toString(), + ), + ) + }, + trailingIcon = { + Icon( + modifier = Modifier + .size(InputChipDefaults.IconSize) + .clickable { onResetClick(suggestion.name) }, + imageVector = Icons.Rounded.RestartAlt, + contentDescription = stringResource( + R.string.variable_reset_content_description, + suggestion.name, + ), + ) + }, + ) + } + } + } + } +} + +private val previewSuggestions = listOf( + VariableSuggestion("counter", 3), + VariableSuggestion("mode", 1), + VariableSuggestion("hits", 0), +) + +@Composable +private fun Preview( + modifier: Modifier = Modifier, + name: String = "counter", + value: String = "1", + suggestions: List = previewSuggestions, + nameError: String? = null, +) { + KeyMapperTheme { + VariableConfigContent( + modifier = modifier.padding(16.dp), + name = name, + value = value, + suggestions = suggestions, + nameError = nameError, + operationIcon = { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Add", + ) + }, + operationLabel = "Add", + ) + } +} + +@Preview +@Composable +private fun VariableConfigRowPreview() { + KeyMapperTheme { + Surface { + Preview(suggestions = emptyList()) + } + } +} + +@Preview +@Composable +private fun VariableConfigRowNoSuggestionsPreview() { + KeyMapperTheme { + Surface { + Preview(suggestions = emptyList()) + } + } +} + +@Preview +@Composable +private fun VariableConfigRowEmptyPreview() { + KeyMapperTheme { + Surface { + Preview(name = "", value = "", suggestions = emptyList()) + } + } +} + +@Preview +@Composable +private fun VariableConfigRowErrorPreview() { + KeyMapperTheme { + Surface { + Preview(name = "1counter", nameError = "Invalid variable name") + } + } +} + +@Preview +@Composable +private fun VariableConfigRowTrailingPreview() { + KeyMapperTheme { + Surface { + Preview(suggestions = emptyList()) + } + } +} + +@Preview +@Composable +private fun VariableConfigRowNarrowPreview() { + KeyMapperTheme { + Surface { + Preview( + modifier = Modifier.width(320.dp), + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableOperation.kt b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableOperation.kt new file mode 100644 index 0000000000..c8653755b8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableOperation.kt @@ -0,0 +1,14 @@ +package io.github.sds100.keymapper.base.variables + +import kotlinx.serialization.Serializable + +@Serializable +enum class VariableOperation { + ADD, + SUBTRACT, + ; + + fun next(): VariableOperation { + return entries[(ordinal + 1) % entries.size] + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableSuggestion.kt b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableSuggestion.kt new file mode 100644 index 0000000000..096ddfda0d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableSuggestion.kt @@ -0,0 +1,3 @@ +package io.github.sds100.keymapper.base.variables + +data class VariableSuggestion(val name: String, val value: Long) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableValueFilter.kt b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableValueFilter.kt new file mode 100644 index 0000000000..bd80301fa8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariableValueFilter.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.base.variables + +/** + * Only a minus sign at the start and digits make sense, and filtering as it is typed avoids + * having to show an error for anything else. + */ +fun String.filterVariableValue(): String { + val digits = filter(Char::isDigit) + + return if (startsWith('-')) { + "-$digits" + } else { + digits + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/variables/VariablesUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariablesUseCase.kt new file mode 100644 index 0000000000..c622fb5c62 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/variables/VariablesUseCase.kt @@ -0,0 +1,155 @@ +package io.github.sds100.keymapper.base.variables + +import io.github.sds100.keymapper.base.actions.ActionData +import io.github.sds100.keymapper.base.actions.ActionDataEntityMapper +import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.data.repositories.KeyMapRepository +import io.github.sds100.keymapper.data.repositories.VariableRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +interface VariablesUseCase { + /** + * The variables that any key map already refers to, so that the user can pick one instead of + * typing the name again. Nothing extra is persisted to build this. + */ + val suggestions: Flow> + + /** + * Only for observing when a variable changes. Use [getValues] to read them. + */ + val values: Flow> + + fun getValues(): Map + + fun setValue(name: String, value: Long): KMResult + + /** + * Reads the variable, does the operation, then stores the result. A variable that has never + * been set counts as 0. + */ + fun modify(name: String, operation: VariableOperation, value: Long): KMResult + + fun reset(name: String) + fun resetAll() +} + +@Singleton +class VariablesUseCaseImpl @Inject constructor( + private val keyMapRepository: KeyMapRepository, + private val variableRepository: VariableRepository, + private val configKeyMapState: ConfigKeyMapState, +) : VariablesUseCase { + + override val values: Flow> = variableRepository.values + + override val suggestions: Flow> = + combine( + namesInKeyMaps(), + namesInConfiguredKeyMap(), + variableRepository.values, + ) { savedNames, configuredNames, values -> + savedNames.plus(configuredNames) + .distinct() + .sorted() + .map { name -> VariableSuggestion(name, values[name] ?: 0L) } + }.flowOn(Dispatchers.IO) + + override fun getValues(): Map { + return variableRepository.values.value + } + + override fun setValue(name: String, value: Long): KMResult { + variableRepository.set(name, value) + + return Success(value) + } + + override fun modify(name: String, operation: VariableOperation, value: Long): KMResult { + val current = variableRepository.values.value[name] ?: 0L + + val newValue = try { + when (operation) { + // Adding and subtracting overflow silently so they must be done with the + // methods that throw instead. + VariableOperation.ADD -> Math.addExact(current, value) + + VariableOperation.SUBTRACT -> Math.subtractExact(current, value) + } + } catch (e: ArithmeticException) { + return KMError.NumberOverflow + } + + variableRepository.set(name, newValue) + + return Success(newValue) + } + + override fun reset(name: String) { + variableRepository.delete(name) + } + + override fun resetAll() { + variableRepository.deleteAll() + } + + /** + * The key map being configured may not have been saved yet so it isn't in the repository. + */ + private fun namesInConfiguredKeyMap(): Flow> { + return configKeyMapState.keyMap.map { state -> + val keyMap = state.dataOrNull() ?: return@map emptyList() + + val actionNames = keyMap.actionList + .map { it.data } + .mapNotNull { it.variableNameOrNull() } + + val constraintNames = keyMap.constraintState.allConstraints + .map { it.data } + .filterIsInstance() + .map { it.name } + + actionNames.plus(constraintNames) + } + } + + private fun namesInKeyMaps(): Flow> { + return keyMapRepository.getAll().map { keyMapList -> + keyMapList + .flatMap { keyMap -> + val actionNames = keyMap.actionList + .filterNotNull() + .mapNotNull { ActionDataEntityMapper.fromEntity(it) } + .mapNotNull { it.variableNameOrNull() } + + val constraintNames = keyMap.constraintList + .map { ConstraintEntityMapper.fromEntity(it).data } + .filterIsInstance() + .map { it.name } + + actionNames.plus(constraintNames) + } + .distinct() + .sorted() + } + } +} + +private fun ActionData.variableNameOrNull(): String? { + return when (this) { + is ActionData.SetVariable -> name + is ActionData.ModifyVariable -> name + else -> null + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 5f7a574208..f6713d8da2 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -66,12 +66,29 @@ Enabled Disabled + Undo + Redo + (no name) + Edit key map name + Save key map name + Key map enabled + Key map disabled Reset Once you have enabled the device admin, you must DEACTIVATE it if you want to uninstall Key Mapper. Wait %sms Wait %ss + %sms + %ss + Repeat + Repeat %dx + Toggle repeat + Hold + Toggle hold + Hold & repeat + Hold & repeat %dx + Toggle hold & repeat Start activity: %s Start service: %s @@ -254,8 +271,8 @@ AND OR - App in foreground - App not in foreground + App is open + App not open Bluetooth device is connected Bluetooth device is disconnected Screen is on @@ -336,12 +353,12 @@ Charging Discharging - Hinge closed - Hinge open - Hinge is closed - Hinge is open - Hinge is not closed - Hinge is not open + Folded + Unfolded + Folded + Unfolded + Device is not folded + Device is not unfolded Notification panel is showing Notification panel is not showing @@ -761,6 +778,7 @@ Customize your experience Key maps Data management + On-screen keyboard options Power user options Debugging Notifications @@ -786,6 +804,12 @@ Use Expert Mode Advanced detection of key events and more + Pause/resume by intent + Pause, resume, or toggle all key maps from another app + + Enable/disable groups by intent + Enable, disable, or toggle a group from another app + Light Dark System @@ -890,6 +914,7 @@ Your device doesn\'t support device policy enforcement. Your device doesn\'t have a camera flash. Your device can not use telephony features. Is a SIM card inserted? + Your device doesn\'t have a hinge. Can\'t find the keyboard settings page! Key Mapper needs to be a device administrator! Key Mapper doesn\'t have permission to use that shortcut @@ -941,6 +966,7 @@ Invalid number! Must be at least %s! Must be at most %s! + The variable is too big or too small to store! Battery optimization is turned on! Turn this off because this can randomly stop Key Mapper from working. Denied notification access permission! Invalid! @@ -1062,7 +1088,10 @@ Disable auto brightness Enable auto brightness Increase display brightness + Increase display brightness by %d%% Decrease display brightness + Decrease display brightness by %d%% + Brightness step Toggle night shift Enable night shift Disable night shift @@ -1204,6 +1233,8 @@ Open settings Show power menu + Shut down device + Restart device Toggle airplane mode Enable airplane mode @@ -1354,6 +1385,36 @@ Modify setting TalkBack gesture TalkBack: %s + + + Variables + Variables + Set variable + Modify variable + Add %2$s to %1$s + Subtract %2$s from %1$s + Set %1$s to %2$s + Variable + %1$s is %2$s + %1$s is not %2$s + %1$s is greater than %2$s + %1$s is not greater than %2$s + %1$s is less than %2$s + %1$s is not less than %2$s + Name + Value + %1$s = %2$s + Reset %1$s + Current variables + Reset all values + Enter a name! + Add + Subtract + Set + Equals + Greater than + Less than + Apply to the variable now Choose TalkBack gesture %1$s (%2$s) 1-finger gestures @@ -1886,10 +1947,6 @@ Choose an action Search… Nothing here! - Delete action - Are you sure you want to delete this action? - Yes, delete - Cancel Run these actions when you trigger the key map: Repeating under 20 ms can lag! Adjust for your device and game. Action name @@ -1940,10 +1997,7 @@ Add constraints if you want key maps to only work in some situations. Recently used constraints Remove - Delete constraint - Are you sure you want to delete this constraint? - Yes, delete - Cancel + Edit This key map will only run if these constraints are met. NOT NOT %s @@ -1964,8 +2018,6 @@ Collapse constraint group Add constraint to group Delete constraint group - Delete constraint group - Are you sure you want to delete this group and all of its constraints? Group name Rename constraint group Combine groups with @@ -2072,6 +2124,11 @@ Key Mapper needs access to the local network to connect to the ADB service on your device. Give permission + Disable Auto Blocker + Samsung Auto Blocker prevents Key Mapper from enabling the features it needs on your device. + Not working? + Samsung Auto Blocker might be turned on. Turn it off before you continue. + Incompatible USB configuration You must select \'No data transfer\' as your default USB configuration so that Expert Mode is not killed every time you lock your device. @@ -2303,4 +2360,17 @@ Run this from a computer to check your setup before building it in another app. Key Mapper intent + Any app that can send an intent, such as Tasker or Automate, can pause, resume, or toggle all key maps without opening Key Mapper. Send a broadcast to Key Mapper with these settings. + Pause + Resume + Toggle + + Enable/disable group by intent + Any app that can send an intent, such as Tasker or Automate, can turn a group on and off without opening Key Mapper. Send a broadcast to Key Mapper with these settings. + Specify this group with the following extra + String extra value + Select a group above to see its ID and the ready-made command. + Select a group + Choose which group this intent controls + diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt index 048114e820..a5f7d4fb55 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.base.variables.VariableOperation import io.github.sds100.keymapper.common.utils.PinchScreenType import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.valueOrNull @@ -11,11 +12,80 @@ import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.nullValue import org.junit.Test -/** - * Tests for saving the screen resolution with the coordinate actions in issue #2217. - */ class ActionDataEntityMapperTest { + @Test + fun `set variable action round trips through the entity`() { + val action = ActionData.SetVariable(name = "counter", value = -12) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_VARIABLE_NAME).valueOrNull(), + `is`("counter"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `modify variable action round trips through the entity`() { + val action = ActionData.ModifyVariable( + name = "counter", + operation = VariableOperation.SUBTRACT, + value = -12, + ) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat(entity.type, `is`(ActionEntity.Type.MODIFY_VARIABLE)) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `modify variable action with the set operation is dropped`() { + val entity = ActionEntity( + type = ActionEntity.Type.MODIFY_VARIABLE, + data = "", + extras = listOf( + EntityExtra(ActionEntity.EXTRA_VARIABLE_NAME, "counter"), + EntityExtra(ActionEntity.EXTRA_VARIABLE_OPERATION, "SET"), + EntityExtra(ActionEntity.EXTRA_VARIABLE_VALUE, "2"), + ), + ) + + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(nullValue())) + } + + @Test + fun `set variable action with a value that is not a number is dropped`() { + // A backup file can be edited by hand + val entity = ActionEntity( + type = ActionEntity.Type.SET_VARIABLE, + data = "", + extras = listOf( + EntityExtra(ActionEntity.EXTRA_VARIABLE_NAME, "counter"), + EntityExtra(ActionEntity.EXTRA_VARIABLE_VALUE, "not a number"), + ), + ) + + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(nullValue())) + } + + @Test + fun `modify variable action with an unknown operation is dropped`() { + val entity = ActionEntity( + type = ActionEntity.Type.MODIFY_VARIABLE, + data = "", + extras = listOf( + EntityExtra(ActionEntity.EXTRA_VARIABLE_NAME, "counter"), + EntityExtra(ActionEntity.EXTRA_VARIABLE_OPERATION, "MULTIPLY"), + EntityExtra(ActionEntity.EXTRA_VARIABLE_VALUE, "2"), + ), + ) + + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(nullValue())) + } + @Test fun `open app action with no app name extra is loaded with a null saved app name`() { val entity = ActionEntity( @@ -188,4 +258,78 @@ class ActionDataEntityMapperTest { assertThat(malformedValue, action.screenResolution, `is`(nullValue())) } } + + @Test + fun `increase brightness action with a non-default step round trips through the entity`() { + val action = ActionData.Brightness.Increase(stepPercent = 25) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT).valueOrNull(), + `is`("25"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `dont save an extra when the increase brightness action has no custom step`() { + val action = ActionData.Brightness.Increase(stepPercent = null) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT).valueOrNull(), + `is`(nullValue()), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `load no brightness step for an increase brightness action saved before it existed`() { + // GIVEN an entity saved by an older version of the app, with no extra at all. + val entity = + ActionEntity(type = ActionEntity.Type.SYSTEM_ACTION, data = "increase_brightness") + + val action = ActionDataEntityMapper.fromEntity(entity) + + assertThat((action as ActionData.Brightness.Increase).stepPercent, `is`(nullValue())) + } + + @Test + fun `decrease brightness action with a non-default step round trips through the entity`() { + val action = ActionData.Brightness.Decrease(stepPercent = 25) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT).valueOrNull(), + `is`("25"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `dont save an extra when the decrease brightness action has no custom step`() { + val action = ActionData.Brightness.Decrease(stepPercent = null) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_BRIGHTNESS_STEP_PERCENT).valueOrNull(), + `is`(nullValue()), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `load no brightness step for a decrease brightness action saved before it existed`() { + // GIVEN an entity saved by an older version of the app, with no extra at all. + val entity = + ActionEntity(type = ActionEntity.Type.SYSTEM_ACTION, data = "decrease_brightness") + + val action = ActionDataEntityMapper.fromEntity(entity) + + assertThat((action as ActionData.Brightness.Decrease).stepPercent, `is`(nullValue())) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index fedf064c79..867f126b5e 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -6,10 +6,12 @@ import io.github.sds100.keymapper.base.system.devices.FakeDevicesAdapter import io.github.sds100.keymapper.base.system.inputmethod.FakeInputMethodAdapter import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PinchScreenType import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.display.DisplayInfo import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.popup.ToastAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -92,6 +94,7 @@ class PerformActionsUseCaseTest { notificationAdapter = mock(), settingsAdapter = mock(), vibratorAdapter = mock(), + variablesUseCase = mock(), ) } @@ -118,13 +121,24 @@ class PerformActionsUseCaseTest { verify(mockToastAdapter, never()).show(any(), any()) } + private fun stubDisplaySize(size: SizeKM) { + whenever(mockDisplayAdapter.getDisplay(any())).doReturn( + DisplayInfo( + id = 0, + activeSize = size, + rotation = Orientation.ORIENTATION_0, + supportedSizes = setOf(size), + ), + ) + } + /** * issue #2217 */ @Test fun `scale tap screen action to the current display resolution`() = runTest(testDispatcher) { // GIVEN the coordinate was picked on a 1080x2400 display and the display is now 1440x3200. - whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + stubDisplaySize(SizeKM(1440, 3200)) whenever(mockAccessibilityService.tapScreen(any(), any(), any())).doReturn(Success(Unit)) val action = ActionData.TapScreen( @@ -148,7 +162,7 @@ class PerformActionsUseCaseTest { fun `dont scale tap screen action created before the resolution was saved`() = runTest(testDispatcher) { // GIVEN - whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + stubDisplaySize(SizeKM(1440, 3200)) whenever(mockAccessibilityService.tapScreen(any(), any(), any())) .doReturn(Success(Unit)) @@ -173,7 +187,7 @@ class PerformActionsUseCaseTest { fun `scale both ends of a swipe screen action to the current display resolution`() = runTest(testDispatcher) { // GIVEN - whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + stubDisplaySize(SizeKM(1440, 3200)) whenever( mockAccessibilityService.swipeScreen( any(), @@ -218,7 +232,7 @@ class PerformActionsUseCaseTest { @Test fun `scale the centre and the distance of a pinch screen action`() = runTest(testDispatcher) { // GIVEN - whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + stubDisplaySize(SizeKM(1440, 3200)) whenever( mockAccessibilityService.pinchScreen( any(), @@ -265,7 +279,7 @@ class PerformActionsUseCaseTest { runTest(testDispatcher) { // GIVEN the coordinate was picked on a portrait display and the display is now // landscape at the same resolution. - whenever(mockDisplayAdapter.size).doReturn(SizeKM(2400, 1080)) + stubDisplaySize(SizeKM(2400, 1080)) whenever(mockAccessibilityService.tapScreen(any(), any(), any())) .doReturn(Success(Unit)) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt index b3fdbad8ac..d054faf441 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import io.github.sds100.keymapper.base.variables.VariableComparison import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.data.entities.ConstraintEntity import io.github.sds100.keymapper.data.entities.EntityExtra @@ -348,6 +349,53 @@ class ConstraintEntityMapperTest { assertThat(ConstraintEntityMapper.fromEntity(entity), `is`(constraint)) } + @Test + fun `variable constraint round trips through the entity`() { + val constraint = Constraint( + uid = UID, + data = ConstraintData.Variable( + name = "counter", + comparison = VariableComparison.GREATER_THAN, + value = -5, + ), + ) + + val entity = ConstraintEntityMapper.toEntity(constraint) + + assertThat( + entity.extras.getData(ConstraintEntity.EXTRA_VARIABLE_NAME).valueOrNull(), + `is`("counter"), + ) + assertThat(ConstraintEntityMapper.fromEntity(entity), `is`(constraint)) + } + + @Test + fun `variable constraint with a value that is not a number falls back to zero`() { + // A backup file can be edited by hand, and throwing while a key is being handled would + // take down the accessibility service. + val entity = ConstraintEntity( + uid = UID, + type = ConstraintEntity.VARIABLE, + EntityExtra(ConstraintEntity.EXTRA_VARIABLE_NAME, "counter"), + EntityExtra(ConstraintEntity.EXTRA_VARIABLE_COMPARISON, "GREATER_THAN"), + EntityExtra(ConstraintEntity.EXTRA_VARIABLE_VALUE, "not a number"), + ) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`( + Constraint( + uid = UID, + data = ConstraintData.Variable( + name = "counter", + comparison = VariableComparison.GREATER_THAN, + value = 0, + ), + ), + ), + ) + } + private fun assertMigratedToNot(entity: ConstraintEntity, expectedData: ConstraintData) { assertThat( ConstraintEntityMapper.fromEntity(entity), diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt index 6df3726b64..139dddd50e 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import android.content.pm.PackageManager import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.apps.PackageManagerAdapter @@ -23,6 +24,7 @@ class ConstraintErrorSnapshotTest { private lateinit var mockPackageManagerAdapter: PackageManagerAdapter private lateinit var mockPermissionAdapter: PermissionAdapter private lateinit var mockInputMethodAdapter: InputMethodAdapter + private lateinit var mockSystemFeatureAdapter: SystemFeatureAdapter private lateinit var snapshot: ConstraintErrorSnapshot @Before @@ -30,7 +32,7 @@ class ConstraintErrorSnapshotTest { mockPackageManagerAdapter = mock() mockPermissionAdapter = mock() mockInputMethodAdapter = mock() - val mockSystemFeatureAdapter: SystemFeatureAdapter = mock() + mockSystemFeatureAdapter = mock() val mockCameraAdapter: CameraAdapter = mock() snapshot = LazyConstraintErrorSnapshot( @@ -114,4 +116,19 @@ class ConstraintErrorSnapshotTest { `is`(KMError.AppNotFound("com.uninstalled", "Example") as KMError?), ) } + + @Test + fun `unsupported constraint returns supported error before permission error`() { + whenever(mockSystemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) + .thenReturn(false) + + val constraint = Constraint( + data = ConstraintData.BtDeviceConnected("00:11:22:33:44:55", "Headphones"), + ) + + assertThat( + snapshot.getError(constraint), + `is`(KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) as KMError?), + ) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/IsConstraintSupportedUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/IsConstraintSupportedUseCaseTest.kt new file mode 100644 index 0000000000..63227f835b --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/IsConstraintSupportedUseCaseTest.kt @@ -0,0 +1,72 @@ +package io.github.sds100.keymapper.base.constraints + +import android.content.pm.PackageManager +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.system.camera.CameraAdapter +import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class IsConstraintSupportedUseCaseTest { + + private lateinit var mockSystemFeatureAdapter: SystemFeatureAdapter + private lateinit var mockCameraAdapter: CameraAdapter + private lateinit var useCase: IsConstraintSupportedUseCase + + @Before + fun init() { + mockSystemFeatureAdapter = mock() + mockCameraAdapter = mock() + + useCase = IsConstraintSupportedUseCaseImpl(mockSystemFeatureAdapter, mockCameraAdapter) + } + + @Test + fun `bluetooth constraint is unsupported without bluetooth feature`() { + whenever(mockSystemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) + .thenReturn(false) + + assertThat( + useCase.isSupported(ConstraintId.BT_DEVICE_CONNECTED), + `is`(KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH)), + ) + } + + @Test + fun `bluetooth constraint is supported with bluetooth feature`() { + whenever(mockSystemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) + .thenReturn(true) + + assertThat(useCase.isSupported(ConstraintId.BT_DEVICE_CONNECTED), `is`(nullValue())) + } + + @Test + fun `hinge constraint is unsupported without hinge sensor`() { + whenever( + mockSystemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_SENSOR_HINGE_ANGLE), + ).thenReturn(false) + + assertThat( + useCase.isSupported(ConstraintId.HINGE_OPEN), + `is`(KMError.SystemFeatureNotSupported(PackageManager.FEATURE_SENSOR_HINGE_ANGLE)), + ) + } + + @Test + fun `flashlight constraint is unsupported without any flash`() { + assertThat( + useCase.isSupported(ConstraintId.FLASHLIGHT_ON), + `is`(KMError.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH)), + ) + } + + @Test + fun `constraint with no hardware requirement is supported`() { + assertThat(useCase.isSupported(ConstraintId.SCREEN_ON), `is`(nullValue())) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt index f01832a60f..4968595ae9 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter @@ -15,6 +16,7 @@ import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -24,6 +26,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -41,6 +44,7 @@ class SystemBridgeSetupUseCaseTest { private lateinit var mockPermissionAdapter: PermissionAdapter private lateinit var mockAccessibilityServiceAdapter: AccessibilityServiceAdapter private lateinit var mockNetworkAdapter: NetworkAdapter + private lateinit var mockPackageManagerAdapter: PackageManagerAdapter @Before fun init() { @@ -52,6 +56,9 @@ class SystemBridgeSetupUseCaseTest { mockPermissionAdapter = mock() mockAccessibilityServiceAdapter = mock() mockNetworkAdapter = mock() + mockPackageManagerAdapter = mock() + whenever(mockPackageManagerAdapter.onPackagesChanged).thenReturn(emptyFlow()) + whenever(mockPackageManagerAdapter.isAppInstalled(any())).thenReturn(false) useCase = SystemBridgeSetupUseCaseImpl( preferences = fakePreferences, @@ -62,6 +69,7 @@ class SystemBridgeSetupUseCaseTest { permissionAdapter = mockPermissionAdapter, accessibilityServiceAdapter = mockAccessibilityServiceAdapter, networkAdapter = mockNetworkAdapter, + packageManagerAdapter = mockPackageManagerAdapter, clock = mock(), ) } @@ -173,6 +181,7 @@ class SystemBridgeSetupUseCaseTest { permissionAdapter = mockPermissionAdapter, accessibilityServiceAdapter = mockAccessibilityServiceAdapter, networkAdapter = mockNetworkAdapter, + packageManagerAdapter = mockPackageManagerAdapter, clock = mock(), ) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt index 7a01439985..fd86fa73e7 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt @@ -4,6 +4,7 @@ import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintSnapshot import io.github.sds100.keymapper.base.constraints.NotificationConstraintMatcher +import io.github.sds100.keymapper.base.variables.VariableComparison import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PhysicalOrientation import io.github.sds100.keymapper.common.utils.SizeKM @@ -41,6 +42,7 @@ class TestConstraintSnapshot( val isNotificationPanelShowing: Boolean = false, val displaySize: SizeKM = SizeKM(1080, 1920), val postedNotifications: List = emptyList(), + val variables: Map = emptyMap(), ) : ConstraintSnapshot { override fun isSatisfied(constraint: Constraint): Boolean { @@ -137,6 +139,16 @@ class TestConstraintSnapshot( hingeState is HingeState.Available && hingeState.isOpen() ConstraintData.NotificationPanelShowing -> isNotificationPanelShowing + + is ConstraintData.Variable -> { + val currentValue = variables[data.name] ?: 0L + + when (data.comparison) { + VariableComparison.EQUALS -> currentValue == data.value + VariableComparison.GREATER_THAN -> currentValue > data.value + VariableComparison.LESS_THAN -> currentValue < data.value + } + } } != constraint.isNot if (isSatisfied) { diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/UndoRedoTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/UndoRedoTest.kt new file mode 100644 index 0000000000..1c9398a26c --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/UndoRedoTest.kt @@ -0,0 +1,50 @@ +package io.github.sds100.keymapper.base.utils + +import io.github.sds100.keymapper.common.utils.UndoRedo +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +class UndoRedoTest { + @Test + fun `undo and redo walk through recorded values`() { + val undoRedo = UndoRedo() + undoRedo.reset(1) + undoRedo.record(2) + undoRedo.record(3) + + assertThat(undoRedo.state.value.canUndo, `is`(true)) + assertThat(undoRedo.state.value.canRedo, `is`(false)) + assertThat(undoRedo.undo(), `is`(2)) + assertThat(undoRedo.undo(), `is`(1)) + assertThat(undoRedo.undo(), `is`(nullValue())) + assertThat(undoRedo.state.value.canUndo, `is`(false)) + assertThat(undoRedo.redo(), `is`(2)) + assertThat(undoRedo.redo(), `is`(3)) + assertThat(undoRedo.redo(), `is`(nullValue())) + } + + @Test + fun `recording after an undo discards the redo branch`() { + val undoRedo = UndoRedo() + undoRedo.reset(1) + undoRedo.record(2) + undoRedo.undo() + undoRedo.record(3) + + assertThat(undoRedo.state.value.canRedo, `is`(false)) + assertThat(undoRedo.undo(), `is`(1)) + } + + @Test + fun `reset clears history`() { + val undoRedo = UndoRedo() + undoRedo.reset(1) + undoRedo.record(2) + undoRedo.reset(5) + + assertThat(undoRedo.state.value.canUndo, `is`(false)) + assertThat(undoRedo.state.value.canRedo, `is`(false)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/variables/VariablesUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/variables/VariablesUseCaseTest.kt new file mode 100644 index 0000000000..639cd2032b --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/variables/VariablesUseCaseTest.kt @@ -0,0 +1,226 @@ +package io.github.sds100.keymapper.base.variables + +import io.github.sds100.keymapper.base.actions.Action +import io.github.sds100.keymapper.base.actions.ActionData +import io.github.sds100.keymapper.base.actions.ActionDataEntityMapper +import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper +import io.github.sds100.keymapper.base.constraints.ConstraintGroup +import io.github.sds100.keymapper.base.constraints.ConstraintState +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.valueOrNull +import io.github.sds100.keymapper.data.entities.KeyMapEntity +import io.github.sds100.keymapper.data.repositories.KeyMapRepository +import io.github.sds100.keymapper.data.repositories.VariableRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class VariablesUseCaseTest { + + private lateinit var variableRepository: FakeVariableRepository + private lateinit var keyMapRepository: FakeKeyMapRepository + private lateinit var configKeyMap: MutableStateFlow> + private lateinit var useCase: VariablesUseCase + + @Before + fun init() { + variableRepository = FakeVariableRepository() + keyMapRepository = FakeKeyMapRepository() + configKeyMap = MutableStateFlow(State.Loading) + val configKeyMapState = mock { + on { keyMap } doReturn configKeyMap + } + useCase = VariablesUseCaseImpl(keyMapRepository, variableRepository, configKeyMapState) + } + + @Test + fun `suggestions include variables in the key map being configured before it is saved`() = + runTest { + configKeyMap.value = State.Data( + KeyMap( + actionList = listOf(Action(data = setVariable("counter"))), + constraintState = ConstraintState( + groups = listOf( + ConstraintGroup( + constraints = listOf(Constraint(data = variableConstraint("mode"))), + ), + ), + ), + ), + ) + + assertThat( + useCase.suggestions.first(), + `is`(listOf(VariableSuggestion("counter", 0), VariableSuggestion("mode", 0))), + ) + } + + @Test + fun `a variable that has never been set counts as zero`() { + assertThat(useCase.modify("counter", VariableOperation.ADD, 1).valueOrNull(), `is`(1L)) + } + + @Test + fun `adding and subtracting change the stored value`() { + useCase.setValue("counter", 10) + useCase.modify("counter", VariableOperation.ADD, 5) + useCase.modify("counter", VariableOperation.SUBTRACT, 3) + + assertThat(useCase.getValues()["counter"], `is`(12L)) + } + + @Test + fun `setting replaces the value rather than combining with it`() { + useCase.setValue("mode", 7) + useCase.setValue("mode", 2) + + assertThat(useCase.getValues()["mode"], `is`(2L)) + } + + @Test + fun `adding past the maximum is an error and leaves the value alone`() { + useCase.setValue("counter", Long.MAX_VALUE) + + val result = useCase.modify("counter", VariableOperation.ADD, 1) + + assertThat(result, `is`(KMError.NumberOverflow)) + assertThat(useCase.getValues()["counter"], `is`(Long.MAX_VALUE)) + } + + @Test + fun `subtracting past the minimum is an error and leaves the value alone`() { + useCase.setValue("counter", Long.MIN_VALUE) + + val result = useCase.modify("counter", VariableOperation.SUBTRACT, 1) + + assertThat(result, `is`(KMError.NumberOverflow)) + assertThat(useCase.getValues()["counter"], `is`(Long.MIN_VALUE)) + } + + @Test + fun `resetting a variable makes it read as zero again`() { + useCase.setValue("counter", 9) + + useCase.reset("counter") + + assertThat(useCase.getValues()["counter"], `is`(nullValue())) + assertThat(useCase.modify("counter", VariableOperation.ADD, 1), `is`(Success(1L))) + } + + @Test + fun `resetting everything clears a variable that no key map refers to`() { + useCase.setValue("orphan", 5) + + useCase.resetAll() + + assertThat(useCase.getValues().isEmpty(), `is`(true)) + } + + @Test + fun `suggestions include names from both actions and constraints`() = runTest { + keyMapRepository.keyMaps.value = listOf( + keyMapEntity(actions = listOf(setVariable("counter"))), + keyMapEntity(constraints = listOf(variableConstraint("mode"))), + ) + + useCase.setValue("counter", 3) + + assertThat( + useCase.suggestions.first(), + `is`(listOf(VariableSuggestion("counter", 3), VariableSuggestion("mode", 0))), + ) + } + + @Test + fun `a variable no key map refers to is not suggested but is still stored`() = runTest { + useCase.setValue("orphan", 5) + + assertThat(useCase.suggestions.first().isEmpty(), `is`(true)) + assertThat(useCase.getValues()["orphan"], `is`(5L)) + } + + @Test + fun `the same name used by many key maps is only suggested once`() = runTest { + keyMapRepository.keyMaps.value = listOf( + keyMapEntity(actions = listOf(setVariable("counter"))), + keyMapEntity(actions = listOf(setVariable("counter"))), + ) + + assertThat(useCase.suggestions.first().size, `is`(1)) + } + + private fun setVariable(name: String): ActionData { + return ActionData.SetVariable(name, 1) + } + + private fun variableConstraint(name: String): ConstraintData { + return ConstraintData.Variable(name, VariableComparison.EQUALS, 2) + } + + private fun keyMapEntity( + actions: List = emptyList(), + constraints: List = emptyList(), + ): KeyMapEntity { + return KeyMapEntity( + id = 0, + actionList = actions.map { ActionDataEntityMapper.toEntity(it) }, + constraintList = constraints.map { + ConstraintEntityMapper.toEntity(Constraint(data = it)) + }, + ) + } +} + +private class FakeVariableRepository : VariableRepository { + override val values = MutableStateFlow>(emptyMap()) + + override fun set(name: String, value: Long) { + values.value = values.value.plus(name to value) + } + + override fun delete(name: String) { + values.value = values.value.minus(name) + } + + override fun deleteAll() { + values.value = emptyMap() + } +} + +private class FakeKeyMapRepository : KeyMapRepository { + val keyMaps = MutableStateFlow>(emptyList()) + + override val keyMapList: Flow>> + get() = throw NotImplementedError() + + override fun getAll(): Flow> = keyMaps + override fun getByGroup(groupUid: String?): Flow> = keyMaps + override fun insert(vararg keyMap: KeyMapEntity) = Unit + override fun update(vararg keyMap: KeyMapEntity) = Unit + override suspend fun get(uid: String): KeyMapEntity? = null + override fun delete(vararg uid: String) = Unit + override suspend fun deleteAll() = Unit + override fun count(): Flow = throw NotImplementedError() + override fun duplicate(vararg uid: String) = Unit + override fun enableById(vararg uid: String) = Unit + override fun disableById(vararg uid: String) = Unit + override fun enableByGroup(groupUid: String?) = Unit + override fun disableByGroup(groupUid: String?) = Unit + override fun toggleByGroup(groupUid: String?) = Unit + override fun toggleById(vararg uid: String) = Unit + override fun moveToGroup(groupUid: String?, vararg uid: String) = Unit +} diff --git a/build.gradle.kts b/build.gradle.kts index 78ed67584b..9aa1dc83fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,4 +14,5 @@ plugins { alias(libs.plugins.jlleitschuh.gradle.ktlint) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.dagger.hilt.android) apply false + alias(libs.plugins.roborazzi) apply false } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt index f0759b76b6..ad2e8503c5 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt @@ -24,6 +24,11 @@ abstract class KMError : KMResult() { data object InvalidNumber : KMError() data class NumberTooBig(val max: Int) : KMError() data class NumberTooSmall(val min: Int) : KMError() + + /** + * A variable went outside the range of a signed Long. + */ + data object NumberOverflow : KMError() data object EmptyText : KMError() data object NoIncompatibleKeyboardsInstalled : KMError() data object NoMediaSessions : KMError() diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt index e4f3fb3a75..4903344e83 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt @@ -18,10 +18,13 @@ data class SizeKM(val width: Int, val height: Int) { return false } - val ratio = width.toFloat() / height - val otherRatio = other.width.toFloat() / other.height + val ratio = aspectRatio + val otherRatio = other.aspectRatio val otherRotatedRatio = other.height.toFloat() / other.width return abs(ratio - otherRatio) <= epsilon || abs(ratio - otherRotatedRatio) <= epsilon } } + +val SizeKM.aspectRatio: Float + get() = width.toFloat() / height diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/TimeUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/TimeUtils.kt index 450e438d9b..6412e3afd0 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/TimeUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/TimeUtils.kt @@ -8,4 +8,17 @@ object TimeUtils { fun localeDateFormatter(style: FormatStyle): DateTimeFormatter { return DateTimeFormatter.ofLocalizedTime(style).withLocale(Locale.getDefault()) } + + /** + * @return the milliseconds as seconds without a trailing ".0", e.g. 1000 -> "1", 1500 -> "1.5". + */ + fun formatSeconds(ms: Int, locale: Locale = Locale.getDefault()): String { + val seconds = ms / 1000f + + return if (seconds % 1f == 0f) { + seconds.toInt().toString() + } else { + String.format(locale, "%.1f", seconds) + } + } } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/UndoRedo.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/UndoRedo.kt new file mode 100644 index 0000000000..ca216c5d96 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/UndoRedo.kt @@ -0,0 +1,49 @@ +package io.github.sds100.keymapper.common.utils + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class UndoRedo { + private var history = mutableListOf() + private var index = -1 + + private val _state = MutableStateFlow(UndoRedoState()) + val state: StateFlow = _state.asStateFlow() + + fun record(value: T) { + history = history.subList(0, index + 1).toMutableList() + history.add(value) + index = history.size - 1 + updateState() + } + + fun undo(): T? { + if (index <= 0) return null + index-- + updateState() + return history[index] + } + + fun redo(): T? { + if (index >= history.size - 1) return null + index++ + updateState() + return history[index] + } + + fun reset(value: T) { + history = mutableListOf(value) + index = 0 + updateState() + } + + private fun updateState() { + _state.value = UndoRedoState( + canUndo = index > 0, + canRedo = index < history.size - 1, + ) + } +} + +data class UndoRedoState(val canUndo: Boolean = false, val canRedo: Boolean = false) diff --git a/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/24.json b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/24.json new file mode 100644 index 0000000000..132a81cc76 --- /dev/null +++ b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/24.json @@ -0,0 +1,487 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "b6ab5dd08c70b9aed641c167d364511d", + "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, `constraint_groups` TEXT NOT NULL DEFAULT '[]', `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, `name` 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": "constraintGroups", + "columnName": "constraint_groups", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "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" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + } + ], + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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`)" + } + ] + }, + { + "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, `show_over_status_bar` INTEGER, `show_over_input_method` INTEGER, `position_locked` INTEGER, 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" + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL" + }, + { + "fieldPath": "showOverStatusBar", + "columnName": "show_over_status_bar", + "affinity": "INTEGER" + }, + { + "fieldPath": "showOverInputMethod", + "columnName": "show_over_input_method", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPositionLocked", + "columnName": "position_locked", + "affinity": "INTEGER" + } + ], + "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" + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "accessibility_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `text` TEXT, `content_description` TEXT, `class_name` TEXT, `view_resource_id` TEXT, `unique_id` TEXT, `actions` INTEGER NOT NULL, `interacted` INTEGER NOT NULL DEFAULT false, `tooltip` TEXT DEFAULT NULL, `hint` TEXT DEFAULT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "contentDescription", + "columnName": "content_description", + "affinity": "TEXT" + }, + { + "fieldPath": "className", + "columnName": "class_name", + "affinity": "TEXT" + }, + { + "fieldPath": "viewResourceId", + "columnName": "view_resource_id", + "affinity": "TEXT" + }, + { + "fieldPath": "uniqueId", + "columnName": "unique_id", + "affinity": "TEXT" + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interacted", + "columnName": "interacted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "tooltip", + "columnName": "tooltip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "hint", + "columnName": "hint", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6ab5dd08c70b9aed641c167d364511d')" + ] + } +} \ No newline at end of file diff --git a/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/25.json b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/25.json new file mode 100644 index 0000000000..a4d69927ea --- /dev/null +++ b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/25.json @@ -0,0 +1,511 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "52113a3206ef41cad91f5201d3fab2d7", + "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, `constraint_groups` TEXT NOT NULL DEFAULT '[]', `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, `name` 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": "constraintGroups", + "columnName": "constraint_groups", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "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" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + } + ], + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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`)" + } + ] + }, + { + "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, `show_over_status_bar` INTEGER, `show_over_input_method` INTEGER, `position_locked` INTEGER, 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" + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL" + }, + { + "fieldPath": "showOverStatusBar", + "columnName": "show_over_status_bar", + "affinity": "INTEGER" + }, + { + "fieldPath": "showOverInputMethod", + "columnName": "show_over_input_method", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPositionLocked", + "columnName": "position_locked", + "affinity": "INTEGER" + } + ], + "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" + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "accessibility_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `text` TEXT, `content_description` TEXT, `class_name` TEXT, `view_resource_id` TEXT, `unique_id` TEXT, `actions` INTEGER NOT NULL, `interacted` INTEGER NOT NULL DEFAULT false, `tooltip` TEXT DEFAULT NULL, `hint` TEXT DEFAULT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "contentDescription", + "columnName": "content_description", + "affinity": "TEXT" + }, + { + "fieldPath": "className", + "columnName": "class_name", + "affinity": "TEXT" + }, + { + "fieldPath": "viewResourceId", + "columnName": "view_resource_id", + "affinity": "TEXT" + }, + { + "fieldPath": "uniqueId", + "columnName": "unique_id", + "affinity": "TEXT" + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interacted", + "columnName": "interacted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "tooltip", + "columnName": "tooltip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "hint", + "columnName": "hint", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "variables", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + } + ], + "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, '52113a3206ef41cad91f5201d3fab2d7')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt b/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt index 7276456806..1f8a8e0752 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt @@ -18,6 +18,8 @@ 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.VariableRepository +import io.github.sds100.keymapper.data.repositories.VariableRepositoryImpl import javax.inject.Singleton @Module @@ -56,4 +58,8 @@ abstract class DataHiltModule { abstract fun provideFloatingLayoutRepository( impl: RoomFloatingLayoutRepository, ): FloatingLayoutRepository + + @Singleton + @Binds + abstract fun provideVariableRepository(impl: VariableRepositoryImpl): VariableRepository } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 522cc55bd9..60b9bb068b 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -16,6 +16,7 @@ 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.dao.VariableDao import io.github.sds100.keymapper.data.db.typeconverter.ActionListTypeConverter import io.github.sds100.keymapper.data.db.typeconverter.ConstraintListTypeConverter import io.github.sds100.keymapper.data.db.typeconverter.ExtraListTypeConverter @@ -28,6 +29,7 @@ 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.entities.VariableEntity import io.github.sds100.keymapper.data.migration.AutoMigration14To15 import io.github.sds100.keymapper.data.migration.AutoMigration15To16 import io.github.sds100.keymapper.data.migration.AutoMigration16To17 @@ -36,6 +38,7 @@ import io.github.sds100.keymapper.data.migration.AutoMigration19To20 import io.github.sds100.keymapper.data.migration.AutoMigration20To21 import io.github.sds100.keymapper.data.migration.AutoMigration21To22 import io.github.sds100.keymapper.data.migration.AutoMigration22To23 +import io.github.sds100.keymapper.data.migration.AutoMigration23To24 import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -53,6 +56,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::class, GroupEntity::class, AccessibilityNodeEntity::class, + VariableEntity::class, ], version = DATABASE_VERSION, exportSchema = true, @@ -73,6 +77,10 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 AutoMigration(from = 21, to = 22, spec = AutoMigration21To22::class), // Adds the constraint_groups column to key maps AutoMigration(from = 22, to = 23, spec = AutoMigration22To23::class), + // Adds the name column to key maps + AutoMigration(from = 23, to = 24, spec = AutoMigration23To24::class), + // Adds the variables table + AutoMigration(from = 24, to = 25), ], ) @TypeConverters( @@ -85,7 +93,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 = 23 + const val DATABASE_VERSION = 25 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -184,4 +192,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun floatingButtonDao(): FloatingButtonDao abstract fun groupDao(): GroupDao abstract fun accessibilityNodeDao(): AccessibilityNodeDao + abstract fun variableDao(): VariableDao } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt index e37ffd373b..a7a1138024 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt @@ -15,6 +15,7 @@ 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.dao.VariableDao import javax.inject.Singleton @Module @@ -61,6 +62,11 @@ internal class AppDatabaseModule { return database.accessibilityNodeDao() } + @Provides + fun provideVariableDao(database: AppDatabase): VariableDao { + return database.variableDao() + } + private fun createDatabase(context: Context): AppDatabase = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt index 51eabc0605..5792f3cdff 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt @@ -23,6 +23,7 @@ interface KeyMapDao { const val KEY_CONSTRAINT_GROUPS = "constraint_groups" const val KEY_UID = "uid" const val KEY_GROUP_UID = "group_uid" + const val KEY_NAME = "name" } @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_ID = (:id)") @@ -53,6 +54,11 @@ interface KeyMapDao { @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=0 WHERE $KEY_GROUP_UID IS (:groupUid)") suspend fun disableKeyMapByGroup(groupUid: String?) + @Query( + "UPDATE $TABLE_NAME SET $KEY_ENABLED=NOT $KEY_ENABLED WHERE $KEY_GROUP_UID IS (:groupUid)", + ) + suspend fun toggleKeyMapByGroup(groupUid: String?) + @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=0 WHERE $KEY_UID in (:uid)") suspend fun disableKeyMapByUid(vararg uid: String) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/VariableDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/VariableDao.kt new file mode 100644 index 0000000000..a0dc01702b --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/VariableDao.kt @@ -0,0 +1,32 @@ +package io.github.sds100.keymapper.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.github.sds100.keymapper.data.entities.VariableEntity + +@Dao +interface VariableDao { + companion object { + const val TABLE_NAME = "variables" + const val KEY_NAME = "name" + const val KEY_VALUE = "value" + } + + @Query("SELECT * FROM $TABLE_NAME") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("DELETE FROM $TABLE_NAME") + suspend fun deleteAll() + + @Transaction + suspend fun replaceAll(entities: List) { + deleteAll() + insertAll(entities) + } +} diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index adecee8ccc..1c0f5e0972 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -100,12 +100,17 @@ data class ActionEntity( const val EXTRA_NOTIFICATION_TIMEOUT = "extra_notification_timeout" const val EXTRA_TOAST_DURATION = "extra_toast_duration" const val EXTRA_STEP_MEDIA_DURATION = "extra_step_media_duration" + const val EXTRA_BRIGHTNESS_STEP_PERCENT = "extra_brightness_step_percent" const val EXTRA_VIBRATE_MODE = "extra_vibrate_mode" const val EXTRA_VIBRATE_DURATION_MS = "extra_vibrate_duration_ms" const val EXTRA_VIBRATE_EFFECT_TYPE = "extra_vibrate_effect_type" const val VIBRATE_MODE_DURATION = "duration" const val VIBRATE_MODE_PREDEFINED = "predefined" + const val EXTRA_VARIABLE_NAME = "extra_variable_name" + const val EXTRA_VARIABLE_OPERATION = "extra_variable_operation" + const val EXTRA_VARIABLE_VALUE = "extra_variable_value" + // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" const val EXTRA_ACCESSIBILITY_CONTENT_DESCRIPTION = @@ -224,6 +229,8 @@ data class ActionEntity( CREATE_NOTIFICATION, TOAST, VIBRATE, + SET_VARIABLE, + MODIFY_VARIABLE, } constructor( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index 90c5d0628b..4e68f75beb 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt @@ -147,6 +147,8 @@ data class ConstraintEntity( const val TIME = "time" + const val VARIABLE = "constraint_variable" + const val EXTRA_PACKAGE_NAME = "extra_package_name" const val EXTRA_APP_NAME = "extra_app_name" const val EXTRA_BT_ADDRESS = "extra_bluetooth_device_address" @@ -169,6 +171,10 @@ data class ConstraintEntity( const val EXTRA_START_TIME = "extra_start_time" const val EXTRA_END_TIME = "extra_end_time" + const val EXTRA_VARIABLE_NAME = "extra_variable_name" + const val EXTRA_VARIABLE_COMPARISON = "extra_variable_comparison" + const val EXTRA_VARIABLE_VALUE = "extra_variable_value" + val DESERIALIZER = jsonDeserializer { val type by it.json.byString(NAME_TYPE) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt index c516336c78..6ca3f5d1ad 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt @@ -79,6 +79,10 @@ data class KeyMapEntity( @SerializedName(NAME_GROUP_UID) @ColumnInfo(name = KeyMapDao.KEY_GROUP_UID) val groupUid: String? = null, + + @SerializedName(NAME_NAME) + @ColumnInfo(name = KeyMapDao.KEY_NAME) + val name: String? = null, ) : Parcelable { companion object { @@ -93,6 +97,7 @@ data class KeyMapEntity( const val NAME_IS_ENABLED = "isEnabled" const val NAME_UID = "uid" const val NAME_GROUP_UID = "group_uid" + const val NAME_NAME = "name" val DESERIALIZER = jsonDeserializer { val actionListJsonArray by it.json.byArray(NAME_ACTION_LIST) @@ -115,6 +120,7 @@ data class KeyMapEntity( 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) + val name by it.json.byNullableString(NAME_NAME) KeyMapEntity( id = 0, @@ -127,6 +133,7 @@ data class KeyMapEntity( isEnabled = isEnabled, uid = uid, groupUid = groupUid, + name = name, ) } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/VariableEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/VariableEntity.kt new file mode 100644 index 0000000000..098f9204aa --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/VariableEntity.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.data.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.github.sds100.keymapper.data.db.dao.VariableDao + +@Entity(tableName = VariableDao.TABLE_NAME) +data class VariableEntity( + @PrimaryKey + @ColumnInfo(name = VariableDao.KEY_NAME) + val name: String, + @ColumnInfo(name = VariableDao.KEY_VALUE) + val value: Long, +) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration23To24.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration23To24.kt new file mode 100644 index 0000000000..f123ace5be --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration23To24.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration23To24 : AutoMigrationSpec diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt index 122941dce2..0d3b79f4f2 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt @@ -21,6 +21,7 @@ interface KeyMapRepository { fun disableById(vararg uid: String) fun enableByGroup(groupUid: String?) fun disableByGroup(groupUid: String?) + fun toggleByGroup(groupUid: String?) fun toggleById(vararg uid: String) fun moveToGroup(groupUid: String?, vararg uid: String) } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index 571aea111b..999da294c4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -121,6 +121,12 @@ class RoomKeyMapRepository @Inject constructor( } } + override fun toggleByGroup(groupUid: String?) { + coroutineScope.launch(dispatchers.io()) { + keyMapDao.toggleKeyMapByGroup(groupUid) + } + } + override fun disableById(vararg uid: String) { coroutineScope.launch(dispatchers.io()) { for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/VariableRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/VariableRepository.kt new file mode 100644 index 0000000000..c9c1a06b3b --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/VariableRepository.kt @@ -0,0 +1,20 @@ +package io.github.sds100.keymapper.data.repositories + +import kotlinx.coroutines.flow.StateFlow + +/** + * Stores the user defined variables that key maps can manipulate and read. A variable that has + * never been set reads as 0 so there is no distinction between deleting a variable and setting + * it to 0. + */ +interface VariableRepository { + /** + * The values are kept in memory because they must be read synchronously while a key event + * is being handled. + */ + val values: StateFlow> + + fun set(name: String, value: Long) + fun delete(name: String) + fun deleteAll() +} diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/VariableRepositoryImpl.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/VariableRepositoryImpl.kt new file mode 100644 index 0000000000..d49f5b2675 --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/VariableRepositoryImpl.kt @@ -0,0 +1,68 @@ +package io.github.sds100.keymapper.data.repositories + +import io.github.sds100.keymapper.data.db.dao.VariableDao +import io.github.sds100.keymapper.data.entities.VariableEntity +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking + +@OptIn(FlowPreview::class) +@Singleton +class VariableRepositoryImpl @Inject constructor( + private val coroutineScope: CoroutineScope, + private val variableDao: VariableDao, +) : VariableRepository { + + companion object { + /** + * A variable can change as often as an action repeats so the values are only written to + * disk once they have settled. The in memory value is always the authoritative one. + */ + private const val PERSIST_DEBOUNCE_MS = 500L + } + + private val _values: MutableStateFlow> = MutableStateFlow(readFromDisk()) + override val values: StateFlow> = _values.asStateFlow() + + init { + _values + .drop(1) + .debounce(PERSIST_DEBOUNCE_MS.milliseconds) + .onEach { values -> + variableDao.replaceAll(values.map { VariableEntity(it.key, it.value) }) + } + .launchIn(coroutineScope) + } + + override fun set(name: String, value: Long) { + _values.update { it.plus(name to value) } + } + + override fun delete(name: String) { + _values.update { it.minus(name) } + } + + override fun deleteAll() { + _values.update { emptyMap() } + } + + /** + * This blocks so that a key event handled immediately after the process starts reads the + * persisted values rather than an empty map. It only happens once, when this repository is + * created. + */ + private fun readFromDisk(): Map { + return runBlocking { variableDao.getAll() }.associate { it.name to it.value } + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index bfe3fadcf8..76a9a03883 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -26,6 +26,11 @@ desc "Create and deploy production release" lane :production do prompt(text: "Did you write whats new?") + Dir.chdir("..") do + gradle(task: ":screenshots:recordRoborazziDebug") + prompt(text: "Check the new screenshots") + end + version_code = get_properties_value(key: "VERSION_CODE", path: "./app/version.properties") version_name = get_properties_value(key: "VERSION_NAME", path: "./app/version.properties") diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index b8c37c7d86..a72ccd9640 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index 7839b5cd77..cbe2bc93c3 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index a84440af2d..fdb118a27c 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index ed434292cc..274b63d3db 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index 2d9d2acda1..4b778a913a 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index 14be6e67df..e528950bb0 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png index 435707c2ad..1f5abf18e0 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png index cd225f42b6..7f8330f407 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd18a7178b..ad19c4bc33 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,7 @@ mockito-kotlin = "4.0.0" okhttp = "4.12.0" # Need the beta for SDK 37 support robolectric = "4.17-beta-4" +roborazzi = "1.76.0" shizuku = "13.1.5" splitties = "3.0.0" storage-anggrayudi = "0.8.1" @@ -135,6 +136,8 @@ androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.mat androidx-compose-ui-android = { group = "androidx.compose.ui", name = "ui-android" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview-android" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } # Google google-accompanist-drawablepainter = { group = "com.google.accompanist", name = "accompanist-drawablepainter", version.ref = "google-accompanist-drawablepainter" } @@ -172,6 +175,7 @@ mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mo mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito-kotlin" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } hamcrest-all = { group = "org.hamcrest", name = "hamcrest-all", version = "1.3" } # No version variable +roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } # Other anggrayudi-storage = { group = "com.anggrayudi", name = "storage", version.ref = "storage-anggrayudi" } @@ -217,6 +221,7 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt-android" } mozilla-rust-android = { id = "org.mozilla.rust-android-gradle.rust-android", version.ref = "rust-android-gradle" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } [bundles] splitties = [ diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl index 6393821617..dd2ebd214d 100644 --- a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -66,4 +66,8 @@ interface ISystemBridge { * seconds and releasing it kills the system bridge). Defaults to enabled. */ void setEmergencyStopEnabled(boolean enabled) = 27; + + void rebootDevice() = 28; + + void shutdownDevice() = 29; } diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt index 7e28c376d1..1cbe641e5d 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -30,6 +30,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.IBinder +import android.os.IPowerManager import android.os.Looper import android.os.Process import android.os.RemoteException @@ -359,6 +360,11 @@ class SystemBridge : ISystemBridge.Stub() { IUsbManager.Stub.asInterface(ServiceManager.getService(Context.USB_SERVICE)) } + private val powerManager: IPowerManager by lazy { + waitSystemService(Context.POWER_SERVICE) + IPowerManager.Stub.asInterface(ServiceManager.getService(Context.POWER_SERVICE)) + } + private val processPackageName: String = when (Process.myUid()) { Process.ROOT_UID -> "root" Process.SHELL_UID -> "com.android.shell" @@ -779,6 +785,14 @@ class SystemBridge : ISystemBridge.Stub() { .forEach { activityManager.removeTask(it.taskId) } } + override fun rebootDevice() { + powerManager.reboot(false, "keymapper", false) + } + + override fun shutdownDevice() { + powerManager.shutdown(false, "keymapper", false) + } + override fun setRingerMode(ringerMode: Int) { if (audioService == null) { throw UnsupportedOperationException("AudioService not supported") diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt index d65f35a33a..562fb45081 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt @@ -127,6 +127,14 @@ class SystemBridgeSetupControllerImpl @Inject constructor( override fun startWithAdb() { launchStartJob { connectionManager.startWithAdb() + + // Wait for the service to connect before turning off wireless debugging + withTimeoutOrNull(10000L) { + connectionManager.awaitConnected() + } + + // Disable wireless debugging when done + SettingsUtils.putGlobalSetting(ctx, ADB_WIRELESS_SETTING, 0) } } @@ -298,7 +306,9 @@ class SystemBridgeSetupControllerImpl @Inject constructor( } // Wait for the next result - return isAdbPairedResult.filterNotNull().first() + val result = isAdbPairedResult.filterNotNull().first() + + return result } /** @@ -370,6 +380,27 @@ class SystemBridgeSetupControllerImpl @Inject constructor( ) } + override fun openAutoBlockerSettings() { + val autoBlockerIntent = Intent().apply { + component = ComponentName( + "com.samsung.android.rampart", + "com.samsung.android.rampart.ui.MainSettingActivity", + ) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + val fallback = Intent(Settings.ACTION_SECURITY_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + try { + ctx.startActivity(autoBlockerIntent) + } catch (_: ActivityNotFoundException) { + ctx.startActivity(fallback) + } catch (_: SecurityException) { + ctx.startActivity(fallback) + } + } + fun invalidateSettings() { isDeveloperOptionsEnabled.update { getDeveloperOptionsEnabled() } isWirelessDebuggingEnabled.update { getWirelessDebuggingEnabled() } @@ -456,5 +487,7 @@ interface SystemBridgeSetupController { fun launchDeveloperOptions() + fun openAutoBlockerSettings() + suspend fun getShellStartCommand(): KMResult } diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt index 81733fe629..9e190fcb10 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt @@ -4,10 +4,11 @@ enum class SystemBridgeSetupStep(val stepIndex: Int) { ACCESSIBILITY_SERVICE(stepIndex = 0), NOTIFICATION_PERMISSION(stepIndex = 1), ACCESS_LOCAL_NETWORK_PERMISSION(stepIndex = 2), - DEVELOPER_OPTIONS(stepIndex = 3), - WIFI_NETWORK(stepIndex = 4), - WIRELESS_DEBUGGING(stepIndex = 5), - ADB_PAIRING(stepIndex = 6), - START_SERVICE(stepIndex = 7), - STARTED(stepIndex = 8), + SAMSUNG_AUTO_BLOCKER(stepIndex = 3), + DEVELOPER_OPTIONS(stepIndex = 4), + WIFI_NETWORK(stepIndex = 5), + WIRELESS_DEBUGGING(stepIndex = 6), + ADB_PAIRING(stepIndex = 7), + START_SERVICE(stepIndex = 8), + STARTED(stepIndex = 9), } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt index 02a6ed7229..afc4c26d1c 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt @@ -151,6 +151,9 @@ class AndroidPackageManagerAdapter @Inject constructor( try { Intent(Intent.ACTION_VOICE_COMMAND).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK + + // DO NOT use trampoline activity. The voice assistant app + // should be able to launch without unlocking. ctx.startActivity(this) } @@ -164,7 +167,7 @@ class AndroidPackageManagerAdapter @Inject constructor( try { Intent(Intent.ACTION_ASSIST).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK - ctx.startActivity(this) + startActivityWithTrampoline(this) } return Success(Unit) @@ -257,31 +260,35 @@ class AndroidPackageManagerAdapter @Inject constructor( return KMError.AppNotFound(packageName) } } else { - // Use a trampoline activity that will dismiss the keyguard when it is locked. - val intent = if (keyguardManager.isKeyguardLocked) { - Intent(ctx, TrampolineActivity::class.java).apply { - putExtra(TrampolineActivity.EXTRA_INTENT, packageIntent) - } - } else { - packageIntent - } - - val pendingIntent = - PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + startActivityWithTrampoline(packageIntent) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val bundle = ActivityOptions.makeBasic() - .setPendingIntentBackgroundActivityStartMode( - ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED, - ) - .toBundle() + return Success(Unit) + } + } - pendingIntent.send(bundle) - } else { - pendingIntent.send() + private fun startActivityWithTrampoline(intent: Intent) { + // Use a trampoline activity that will dismiss the keyguard when it is locked. + val intent = if (keyguardManager.isKeyguardLocked) { + Intent(ctx, TrampolineActivity::class.java).apply { + putExtra(TrampolineActivity.EXTRA_INTENT, intent) } + } else { + intent + } - return Success(Unit) + val pendingIntent = + PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val bundle = ActivityOptions.makeBasic() + .setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED, + ) + .toBundle() + + pendingIntent.send(bundle) + } else { + pendingIntent.send() } } @@ -303,6 +310,9 @@ class AndroidPackageManagerAdapter @Inject constructor( */ Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA_SECURE).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK + + // DO NOT use trampoline activity. The camera app should be able to launch without + // unlocking. ctx.startActivity(this) } @@ -312,6 +322,9 @@ class AndroidPackageManagerAdapter @Inject constructor( try { Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK + + // DO NOT use trampoline activity. The camera app should be able to launch + // without unlocking. ctx.startActivity(this) } @@ -326,7 +339,7 @@ class AndroidPackageManagerAdapter @Inject constructor( try { Intent(Settings.ACTION_SETTINGS).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK - ctx.startActivity(this) + startActivityWithTrampoline(this) } return Success(Unit) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt index 7dde97c963..0bb73a6e1f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt @@ -7,7 +7,7 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flow interface PackageManagerAdapter { val onPackagesChanged: Flow @@ -39,19 +39,18 @@ interface PackageManagerAdapter { fun getInstallSourcePackageName(): String? } -fun PackageManagerAdapter.isAppInstalledFlow(packageName: String): Flow = callbackFlow { - send(isAppInstalled(packageName)) +fun PackageManagerAdapter.isAppInstalledFlow(packageName: String): Flow = flow { + emit(isAppInstalled(packageName)) onPackagesChanged.collect { - send(isAppInstalled(packageName)) + emit(isAppInstalled(packageName)) } } -fun PackageManagerAdapter.getPackageInfoFlow(packageName: String): Flow = - callbackFlow { - send(getPackageInfo(packageName)) +fun PackageManagerAdapter.getPackageInfoFlow(packageName: String): Flow = flow { + emit(getPackageInfo(packageName)) - onPackagesChanged.collect { - send(getPackageInfo(packageName)) - } + onPackagesChanged.collect { + emit(getPackageInfo(packageName)) } +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 48867bcfda..818b0741da 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.hardware.display.DisplayManager +import android.os.Build import android.provider.Settings import android.view.Display import android.view.OrientationEventListener @@ -19,15 +20,14 @@ import io.github.sds100.keymapper.common.utils.PhysicalOrientation import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.common.utils.getRealDisplaySize import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch /** @@ -45,11 +45,6 @@ class AndroidDisplayAdapter @Inject constructor( ) : DisplayAdapter { companion object { - /** - * How much to change the brightness by. - */ - private const val BRIGHTNESS_CHANGE_STEP = 20 - /** * Tolerance in degrees for orientation detection. * This helps avoid rapid switching at orientation boundaries. @@ -88,24 +83,18 @@ class AndroidDisplayAdapter @Inject constructor( private val displayManager: DisplayManager = ctx.getSystemService()!! - private val _orientation = MutableStateFlow(getDisplayOrientation()) - override val orientation: Flow = _orientation - override val cachedOrientation: Orientation - get() = _orientation.value - private val _physicalOrientation = MutableStateFlow(PhysicalOrientation.PORTRAIT) override val physicalOrientation: Flow = _physicalOrientation override val cachedPhysicalOrientation: PhysicalOrientation get() = _physicalOrientation.value - override val size: SizeKM - get() = ctx.getRealDisplaySize() + override var activityDisplayId: Int? = null override val isAmbientDisplayEnabled: MutableStateFlow = MutableStateFlow(isAodEnabled()) - override val supportedResolutions: MutableStateFlow> = - MutableStateFlow(getSupportedResolutions()) + override val displays: MutableStateFlow> = + MutableStateFlow(fetchAllDisplayInfo()) private val orientationEventListener = object : OrientationEventListener(ctx) { override fun onOrientationChanged(orientationDegrees: Int) { @@ -203,59 +192,47 @@ class AndroidDisplayAdapter @Inject constructor( Settings.System.SCREEN_BRIGHTNESS_MODE, ) == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC - override fun increaseBrightness(): KMResult<*> { + override fun increaseBrightness(stepPercent: Float, display: Int): KMResult<*> { // auto-brightness must be disabled disableAutoBrightness() - val currentBrightness = - SettingsUtils.getSystemSetting(ctx, Settings.System.SCREEN_BRIGHTNESS) - - var newBrightness = if (currentBrightness != null) { - currentBrightness + BRIGHTNESS_CHANGE_STEP - } else { - 255 - } - - // the brightness must be between 0 and 255 - if (newBrightness > 255) { - newBrightness = 255 - } - - val success = - SettingsUtils.putSystemSetting(ctx, Settings.System.SCREEN_BRIGHTNESS, newBrightness) - - return if (success) { - Success(Unit) - } else { - KMError.FailedToModifySystemSetting(Settings.System.SCREEN_BRIGHTNESS) - } + return setBrightness(stepPercent, display) } - override fun decreaseBrightness(): KMResult<*> { + override fun decreaseBrightness(stepPercent: Float, display: Int): KMResult<*> { // auto-brightness must be disabled disableAutoBrightness() - val currentBrightness = - SettingsUtils.getSystemSetting(ctx, Settings.System.SCREEN_BRIGHTNESS) + return setBrightness(-stepPercent, display) + } - var newBrightness = if (currentBrightness != null) { - currentBrightness - BRIGHTNESS_CHANGE_STEP - } else { - 255 - } + private fun setBrightness(delta: Float, display: Int): KMResult<*> { + val currentBrightness = getDisplayBrightness(display) - // the brightness must be between 0 and 255 - if (newBrightness < 0) { - newBrightness = 0 - } + val newBrightness: Float = (currentBrightness + delta).coerceIn(0f, 1f) - val success = - SettingsUtils.putSystemSetting(ctx, Settings.System.SCREEN_BRIGHTNESS, newBrightness) + if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1) { + displayManager.setBrightness( + display, + newBrightness * 100, + DisplayManager.BRIGHTNESS_UNIT_PERCENTAGE, + ) - return if (success) { - Success(Unit) + return Success(Unit) } else { - KMError.FailedToModifySystemSetting(Settings.System.SCREEN_BRIGHTNESS) + val rawBrightness: Int = (newBrightness * 255).roundToInt().coerceIn(1, 255) + + val success = SettingsUtils.putSystemSetting( + ctx, + Settings.System.SCREEN_BRIGHTNESS, + rawBrightness, + ) + + return if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(Settings.System.SCREEN_BRIGHTNESS) + } } } @@ -276,26 +253,31 @@ class AndroidDisplayAdapter @Inject constructor( } } - override fun fetchOrientation(): Orientation { - return _orientation.updateAndGet { getDisplayOrientation() } - } + private fun getDisplayBrightness(displayId: Int): Float { + return if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1) { + displayManager.getBrightness( + displayId, + DisplayManager.BRIGHTNESS_UNIT_PERCENTAGE, + ) / 100f + } else { + val rawBrightness = + SettingsUtils.getSystemSetting(ctx, Settings.System.SCREEN_BRIGHTNESS) + ?: return 255f - private fun getDisplayOrientation(): Orientation = - when (val sdkRotation = displayManager.displays[0].rotation) { - Surface.ROTATION_0 -> Orientation.ORIENTATION_0 - Surface.ROTATION_90 -> Orientation.ORIENTATION_90 - Surface.ROTATION_180 -> Orientation.ORIENTATION_180 - Surface.ROTATION_270 -> Orientation.ORIENTATION_270 - else -> throw Exception("Don't know how to convert $sdkRotation to Orientation") + rawBrightness / 255f } + } private fun isAodEnabled(): Boolean { return SettingsUtils.getSecureSetting(ctx, "doze_always_on") == 1 } private fun onDisplaysChanged() { - _orientation.update { getDisplayOrientation() } - supportedResolutions.update { getSupportedResolutions() } + displays.value = fetchAllDisplayInfo() + } + + override fun getDisplay(id: Int): DisplayInfo? { + return displayManager.getDisplay(id)?.let { buildDisplayInfo(it) } } /** @@ -334,9 +316,33 @@ class AndroidDisplayAdapter @Inject constructor( } } - private fun getSupportedResolutions(): Set { - val display = displayManager.displays.firstOrNull() ?: return emptySet() + private fun fetchAllDisplayInfo(): List { + return displayManager.displays?.filterNotNull()?.map(::buildDisplayInfo) ?: emptyList() + } + + private fun buildDisplayInfo(display: Display): DisplayInfo { + return DisplayInfo( + id = display.displayId, + activeSize = SizeKM( + width = display.mode.physicalWidth, + height = display.mode.physicalHeight, + ), + rotation = getDisplayOrientation(display), + supportedSizes = getSupportedResolutions(display), + ) + } + + private fun getDisplayOrientation(display: Display): Orientation { + return when (val sdkRotation = display.rotation) { + Surface.ROTATION_0 -> Orientation.ORIENTATION_0 + Surface.ROTATION_90 -> Orientation.ORIENTATION_90 + Surface.ROTATION_180 -> Orientation.ORIENTATION_180 + Surface.ROTATION_270 -> Orientation.ORIENTATION_270 + else -> throw Exception("Don't know how to convert $sdkRotation to Orientation") + } + } + private fun getSupportedResolutions(display: Display): Set { return display.supportedModes .map { mode -> SizeKM(mode.physicalWidth, mode.physicalHeight) } .toSet() diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt index cec0291d52..87aee5b435 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayAdapter.kt @@ -3,38 +3,33 @@ package io.github.sds100.keymapper.system.display import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PhysicalOrientation -import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface DisplayAdapter { val isScreenOn: Flow - val orientation: Flow - val cachedOrientation: Orientation + val physicalOrientation: Flow val cachedPhysicalOrientation: PhysicalOrientation - val size: SizeKM val isAmbientDisplayEnabled: Flow /** - * The distinct resolutions supported by the default display, taken from the - * display's supported modes. The dimensions are in the display's natural orientation. + * The display id the last Key Mapper activity is attached to. */ - val supportedResolutions: StateFlow> + val activityDisplayId: Int? + + val displays: StateFlow> + + fun getDisplay(id: Int): DisplayInfo? fun isAutoRotateEnabled(): Boolean fun enableAutoRotate(): KMResult<*> fun disableAutoRotate(): KMResult<*> fun setOrientation(orientation: Orientation): KMResult<*> - /** - * Fetch the orientation and bypass the cached value that updates when the listener changes. - */ - fun fetchOrientation(): Orientation - fun isAutoBrightnessEnabled(): Boolean - fun increaseBrightness(): KMResult<*> - fun decreaseBrightness(): KMResult<*> + fun increaseBrightness(stepPercent: Float, display: Int): KMResult<*> + fun decreaseBrightness(stepPercent: Float, display: Int): KMResult<*> fun enableAutoBrightness(): KMResult<*> fun disableAutoBrightness(): KMResult<*> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayInfo.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayInfo.kt new file mode 100644 index 0000000000..9eb8a5969c --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/DisplayInfo.kt @@ -0,0 +1,11 @@ +package io.github.sds100.keymapper.system.display + +import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.common.utils.SizeKM + +data class DisplayInfo( + val id: Int, + val activeSize: SizeKM, + val rotation: Orientation, + val supportedSizes: Set, +) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt index 9f96f21ed1..e43efcbbf5 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt @@ -30,9 +30,9 @@ class AndroidFoldableAdapter @Inject constructor( private val sensorEventListener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent?) { - event?.let { - if (it.sensor.type == Sensor.TYPE_HINGE_ANGLE && it.values.isNotEmpty()) { - val angle = it.values[0] + event?.let { event -> + if (event.sensor.type == Sensor.TYPE_HINGE_ANGLE && event.values.isNotEmpty()) { + val angle = event.values[0] _hingeState.value = HingeState.Available(angle) } } diff --git a/systemstubs/src/main/aidl/android/os/IPowerManager.aidl b/systemstubs/src/main/aidl/android/os/IPowerManager.aidl new file mode 100644 index 0000000000..f0a2ce47e8 --- /dev/null +++ b/systemstubs/src/main/aidl/android/os/IPowerManager.aidl @@ -0,0 +1,6 @@ +package android.os; + +interface IPowerManager { + void reboot(boolean confirm, String reason, boolean wait); + void shutdown(boolean confirm, String reason, boolean wait); +}