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);
+}