diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index c3d0cecbad..caccddbd8f 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -102,14 +102,14 @@ jobs:
${{ runner.os }}-gradle-
- name: set up JDK 21
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'oracle'
java-version: 21
cache: 'gradle'
- name: Setup Android SDK
- uses: android-actions/setup-android@v2
+ uses: android-actions/setup-android@v4
with:
ndk-version: "27.2.12479018"
diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml
index ab8c9d4025..0ac8ac3b14 100644
--- a/.github/workflows/testing.yml
+++ b/.github/workflows/testing.yml
@@ -114,14 +114,14 @@ jobs:
value: ${{ github.run_number }}
- name: set up JDK 21
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'oracle'
java-version: 21
cache: 'gradle'
- name: Setup Android SDK
- uses: android-actions/setup-android@v2
+ uses: android-actions/setup-android@v4
with:
ndk-version: "27.2.12479018"
diff --git a/.gitignore b/.gitignore
index 9d7b825226..7da1df27a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -97,4 +97,5 @@ app/.env
evdev/build
evdev/.cxx
-evdev/src/main/rust/*/target
\ No newline at end of file
+evdev/src/main/rust/*/target
+.kotlin/*
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5dac9df9c..9ad1fcbbdc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,42 @@
-## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2)
+## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.5.0)
+
+#### 21 September 2026
+
+## Added
+
+- #1341 new constraint for when a notification from a specific app, or with a specific title or text, is posted.
+- #1501 new action to cycle through your enabled keyboards.
+- #2250 redesigned the key map options screen with in-app guides for triggering and enabling/disabling a key map by intent.
+- #701 new action options bottom sheet design.
+- #701 you can now give custom names to actions, to make it easier to identify key maps.
+- #701 redesigned the action list with expandable action cards that show each option and error.
+- #1465 actions can be turned off individually without deleting them.
+- #1479 you can now duplicate an action from the action list.
+- #701 the delay before the next action is now set with a button between actions instead of in the action options.
+- Redesigned the action options with grouped repeat, burst, hold down and delay sections.
+- #752 Constraints can be organised into groups that each have their own AND/OR mode, and the groups are combined with AND or OR.
+- #752 Constraint groups can be given a custom name.
+- #752 Any constraint can be inverted.
+- #752 Redesigned the constraints screen with expandable constraint group cards.
+- #2139 color code disabled/enabled groups.
+- #1987 groups are red if any key maps inside have errors.
+- #2246 adjusted the trigger screen design to match the action and constraint screens.
+- #2249 make key maps more compact in the home screen.
+- #2259 all key maps can now be triggered by intent or shortcut without needing to enable it first.
+- #982 new action to vibrate the device with a custom duration or a predefined haptic effect (Click, Double Click, Heavy Click, Tick).
+- #982 the "vibrate on trigger" key map option can now also be customised with a predefined haptic effect instead of just a duration, via a new settings button next to the Vibrate option.
+- #2243 Redesigned What's New dialog.
+
+## Fixed
+
+- Do not crash if permission not granted for in phone call constraint and trying to perform key map.
+- #2258 remember saved position in key map list when changing them. Do not flash with loading indicator briefly as well.
+- #2167 do not show errors for app not in foreground constraint when the app is uninstalled/disabled.
+- #2262 request local network access permission for HTTP request action on Android 17+.
+- #2261 show the app's saved name instead of a generic label or raw package name for app-related constraints and actions when the app has been uninstalled.
+- #2244 only show "Expert mode started" notification the first time it is auto started. Not every time the Key Mapper process starts and the system bridge connects.
+
+## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.4.0)
#### 12 September 2026
diff --git a/CLAUDE.md b/CLAUDE.md
index 27616596c8..2773c39798 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,8 +24,9 @@ Android app for custom key/gamepad remapping, macros, and on-screen buttons. Sup
./gradlew :base:testDebugUnitTest # single module
# Kotlin lint
-./gradlew ktlintCheck # check
-./gradlew ktlintFormat # auto-fix
+# First run format before doing check
+./gradlew ktlintFormat
+./gradlew ktlintCheck
# Rust (run from evdev/src/main/rust/evdev_manager)
cargo fmt --check
@@ -90,6 +91,9 @@ Example: `#2025 feat: add button to report bug on home screen`
- Use `LocalUriHandler.openUriSafe` extension for URL launching — do not hoist URL launching logic up the call stack
- Use import statements; never use fully qualified names in Compose code
- Write `@Preview` composables for every screen
+- Dialog and modal bottom sheet previews render blank by default because they open a separate
+ window. Use `@Preview(showSystemUi = true)` for both. For bottom sheets, also create the
+ preview's `SheetState` with `initialValue = SheetValue.Expanded`.
## Adding a New Action (10-step checklist)
diff --git a/README.md b/README.md
index 446accb2e2..109931dc00 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
diff --git a/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt b/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt
index 95b18b79c0..965084d7ef 100644
--- a/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt
+++ b/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
@@ -20,6 +21,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
@@ -34,6 +36,7 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme
import io.github.sds100.keymapper.base.constraints.ConfigConstraintsViewModel
import io.github.sds100.keymapper.base.constraints.ConstraintsScreen
import io.github.sds100.keymapper.base.home.HomeKeyMapListScreen
+import io.github.sds100.keymapper.base.home.WhatsNewDialog
import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapScreen
import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapViewModel
import io.github.sds100.keymapper.base.keymaps.KeyMapOptionsScreen
@@ -142,16 +145,24 @@ class MainFragment : Fragment() {
val viewModel: HomeViewModel = hiltViewModel()
+ val whatsNewState by viewModel.whatsNewState.collectAsStateWithLifecycle()
+
+ whatsNewState?.let { state ->
+ WhatsNewDialog(state = state, onDismissRequest = viewModel::onDismissWhatsNew)
+ }
+
HomeKeyMapListScreen(
modifier = Modifier.fillMaxSize(),
viewModel = viewModel.keyMapListViewModel,
snackbarState = snackbarState,
onSettingsClick = viewModel::launchSettings,
onAboutClick = viewModel::launchAbout,
+ onWhatsNewClick = viewModel::onWhatsNewClick,
finishActivity = {
requireActivity().finish()
},
fabBottomPadding = 0.dp,
+ headerExtraContent = {},
)
}
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 0db58ab381..c8afa23700 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
@@ -3,6 +3,7 @@ package io.github.sds100.keymapper.purchasing
import io.github.sds100.keymapper.base.purchasing.PurchasingError
import io.github.sds100.keymapper.base.purchasing.PurchasingManager
import io.github.sds100.keymapper.base.purchasing.RevenueCatEntitlementId
+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
@@ -12,7 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class PurchasingManagerImpl : PurchasingManager {
override val onCompleteProductPurchase: MutableSharedFlow =
MutableSharedFlow()
- override val entitlements: Flow>>> =
+ override val state: Flow>> =
MutableStateFlow(State.Data(PurchasingError.PurchasingNotImplemented))
override suspend fun launchPurchasingFlow(
diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt
index 849ad6f261..cd52d8fc97 100644
--- a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt
+++ b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt
@@ -16,6 +16,7 @@ import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut
import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider
import io.github.sds100.keymapper.base.utils.ui.DialogProvider
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
+import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate
import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager
import javax.inject.Inject
import kotlinx.coroutines.launch
@@ -32,6 +33,7 @@ class ConfigTriggerViewModel @Inject constructor(
setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate,
onboardingTipDelegate: OnboardingTipDelegate,
triggerSetupDelegate: TriggerSetupDelegate,
+ vibrateConfigDelegate: VibrateConfigDelegate,
resourceProvider: ResourceProvider,
navigationProvider: NavigationProvider,
dialogProvider: DialogProvider,
@@ -46,6 +48,7 @@ class ConfigTriggerViewModel @Inject constructor(
systemBridgeConnectionManager = systemBridgeConnectionManager,
onboardingTipDelegate = onboardingTipDelegate,
triggerSetupDelegate = triggerSetupDelegate,
+ vibrateConfigDelegate = vibrateConfigDelegate,
resourceProvider = resourceProvider,
navigationProvider = navigationProvider,
dialogProvider = dialogProvider,
diff --git a/app/version.properties b/app/version.properties
index 5c0a6b7d6c..7a9b55b422 100644
--- a/app/version.properties
+++ b/app/version.properties
@@ -1,2 +1,2 @@
-VERSION_NAME=4.4.0
-VERSION_CODE=264
+VERSION_NAME=4.5.0
+VERSION_CODE=271
diff --git a/base/build.gradle.kts b/base/build.gradle.kts
index 8b0bf62100..bd8e2dbfe9 100644
--- a/base/build.gradle.kts
+++ b/base/build.gradle.kts
@@ -161,7 +161,6 @@ dependencies {
testImplementation(libs.junit.params)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.mockito.core)
- testImplementation(libs.mockito.inline)
testDebugImplementation(libs.androidx.fragment.testing)
// Dependencies for Android instrumented tests
diff --git a/base/src/main/assets/whats-new-app.txt b/base/src/main/assets/whats-new-app.txt
new file mode 100644
index 0000000000..6733687866
--- /dev/null
+++ b/base/src/main/assets/whats-new-app.txt
@@ -0,0 +1,7 @@
+! Group constraints | 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
+* • Turn off, duplicate and rename actions
+* • Redesigned action, constraint and trigger screens
+- • Many other bug fixes and improvements
diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt
index 75321d592d..7d19199139 100644
--- a/base/src/main/assets/whats-new.txt
+++ b/base/src/main/assets/whats-new.txt
@@ -1,11 +1,8 @@
-• "Reduce app killing" tips for Xiaomi/Redmi/Poco devices
-• Sort key maps by enabled/disabled
-• Cycle keyboard language action
-• Custom seek amount for media step actions
-• Export/import key maps on Android TV
-• Fix some crashes when auto starting Expert Mode
-• Fix shell commands failing with Windows line endings
-• Screen tap/swipe/pinch actions now scale to display resolution
+• Group constraints with their own AND/OR logic
+• New vibrate action with custom duration or haptic effects
+• Cycle enabled keyboards action
+• Notification constraint
+• Turn off, duplicate and rename actions
• Many other bug fixes and improvements
📖 View the complete changelog at: http://keymapper.app/changelog
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 dd04c88808..26e7098f8c 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
@@ -16,6 +16,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
import io.github.sds100.keymapper.base.actions.ChooseActionScreen
import io.github.sds100.keymapper.base.actions.ChooseActionViewModel
import io.github.sds100.keymapper.base.actions.ChooseSettingScreen
@@ -25,10 +26,14 @@ import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen
import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel
import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen
import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel
+import io.github.sds100.keymapper.base.constraints.ConfigNotificationConstraintScreen
+import io.github.sds100.keymapper.base.constraints.ConfigNotificationConstraintViewModel
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.keymaps.EnableByIntentScreen
+import io.github.sds100.keymapper.base.keymaps.TriggerByIntentScreen
import io.github.sds100.keymapper.base.logging.LogScreen
import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs
import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl
@@ -101,6 +106,15 @@ fun BaseMainNavHost(
)
}
+ composable {
+ val viewModel: ConfigNotificationConstraintViewModel = hiltViewModel()
+
+ ConfigNotificationConstraintScreen(
+ modifier = Modifier.fillMaxSize(),
+ viewModel = viewModel,
+ )
+ }
+
composable {
val viewModel: ChooseActionViewModel = hiltViewModel()
@@ -189,6 +203,26 @@ fun BaseMainNavHost(
)
}
+ composable { backStackEntry ->
+ val destination = backStackEntry.toRoute()
+
+ TriggerByIntentScreen(
+ modifier = Modifier.fillMaxSize(),
+ keyMapUid = destination.keyMapUid,
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+
+ composable { backStackEntry ->
+ val destination = backStackEntry.toRoute()
+
+ EnableByIntentScreen(
+ modifier = Modifier.fillMaxSize(),
+ keyMapUid = destination.keyMapUid,
+ onBackClick = { navController.popBackStack() },
+ )
+ }
+
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 cd89501ae5..db1df55647 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
@@ -45,7 +45,6 @@ import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeAsyncImpl
import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface
import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCase
import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCaseImpl
-import io.github.sds100.keymapper.base.system.notifications.AndroidNotificationAdapter
import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase
import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCaseImpl
import io.github.sds100.keymapper.base.trigger.RecordTriggerController
@@ -63,16 +62,11 @@ import io.github.sds100.keymapper.common.utils.UuidGenerator
import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter
import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper
import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapperImpl
-import io.github.sds100.keymapper.system.notifications.NotificationAdapter
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class BaseSingletonHiltModule {
- @Singleton
- @Binds
- abstract fun provideNotificationAdapter(impl: AndroidNotificationAdapter): NotificationAdapter
-
@Singleton
@Binds
abstract fun provideAccessibilityAdapter(
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 7dbc421f2f..d664c6373d 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
@@ -61,6 +61,8 @@ import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCase
import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCaseImpl
import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegate
import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegateImpl
+import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate
+import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegateImpl
@Module
@InstallIn(ViewModelComponent::class)
@@ -197,6 +199,10 @@ abstract class BaseViewModelHiltModule {
impl: FixKeyEventActionDelegateImpl,
): FixKeyEventActionDelegate
+ @Binds
+ @ViewModelScoped
+ abstract fun bindVibrateConfigDelegate(impl: VibrateConfigDelegateImpl): VibrateConfigDelegate
+
@Binds
@ViewModelScoped
abstract fun bindExpertModeSetupDelegate(
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
new file mode 100644
index 0000000000..aa47c3cd58
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt
@@ -0,0 +1,10 @@
+package io.github.sds100.keymapper.base
+
+object IntentApi {
+ const val ACTION_TRIGGER_KEYMAP_BY_UID =
+ "io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID"
+ const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID"
+
+ const val TRIGGER_RECEIVER_CLASS =
+ "io.github.sds100.keymapper.api.TriggerKeyMapsBroadcastReceiver"
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt
index 73197e3388..ca467520b1 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt
@@ -29,6 +29,16 @@ data class Action(
val multiplier: Int? = null,
val delayBeforeNextAction: Int? = null,
+
+ /**
+ * A name set by the user that is shown instead of the generated title.
+ */
+ val customName: String? = null,
+
+ /**
+ * Disabled actions are not performed when the key map is triggered.
+ */
+ val isEnabled: Boolean = true,
)
object ActionEntityMapper {
@@ -85,6 +95,11 @@ object ActionEntityMapper {
.valueOrNull()
?.toIntOrNull()
+ val customName = entity.extras
+ .getData(ActionEntity.EXTRA_CUSTOM_NAME)
+ .valueOrNull()
+ ?.takeIf { it.isNotBlank() }
+
return Action(
uid = entity.uid,
data = data,
@@ -98,6 +113,8 @@ object ActionEntityMapper {
holdDownDuration = holdDownDuration,
delayBeforeNextAction = delayBeforeNextAction,
multiplier = multiplier,
+ customName = customName,
+ isEnabled = !entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED),
)
}
@@ -118,6 +135,10 @@ object ActionEntityMapper {
add(EntityExtra(ActionEntity.EXTRA_MULTIPLIER, action.multiplier.toString()))
}
+ if (!action.customName.isNullOrBlank()) {
+ add(EntityExtra(ActionEntity.EXTRA_CUSTOM_NAME, action.customName))
+ }
+
if (keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) &&
action.holdDownDuration != null
) {
@@ -185,6 +206,10 @@ object ActionEntityMapper {
flags = flags.withFlag(ActionEntity.ACTION_FLAG_HOLD_DOWN)
}
+ if (!action.isEnabled) {
+ flags = flags.withFlag(ActionEntity.ACTION_FLAG_DISABLED)
+ }
+
return@map ActionEntity(
type = base.type,
data = base.data,
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 916eefff1b..ed244f3d63 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.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.ShellExecutionMode
import io.github.sds100.keymapper.common.utils.NodeInteractionType
import io.github.sds100.keymapper.common.utils.Orientation
@@ -23,7 +24,7 @@ sealed class ActionData : Comparable {
override fun compareTo(other: ActionData) = id.compareTo(other.id)
@Serializable
- data class App(val packageName: String) : ActionData() {
+ data class App(val packageName: String, val savedAppName: String? = null) : ActionData() {
override val id: ActionId = ActionId.APP
override fun compareTo(other: ActionData) = when (other) {
@@ -33,8 +34,12 @@ sealed class ActionData : Comparable {
}
@Serializable
- data class AppShortcut(val packageName: String?, val shortcutTitle: String, val uri: String) :
- ActionData() {
+ data class AppShortcut(
+ val packageName: String?,
+ val shortcutTitle: String,
+ val uri: String,
+ val savedAppName: String? = null,
+ ) : ActionData() {
override val id: ActionId = ActionId.APP_SHORTCUT
override fun compareTo(other: ActionData) = when (other) {
@@ -251,6 +256,11 @@ sealed class ActionData : Comparable {
override val id = ActionId.CYCLE_KEYBOARD_LANGUAGE
}
+ @Serializable
+ data object CycleKeyboard : ActionData() {
+ override val id = ActionId.CYCLE_KEYBOARD
+ }
+
@Serializable
sealed class DoNotDisturb : ActionData() {
@@ -331,6 +341,7 @@ sealed class ActionData : Comparable {
@Serializable
sealed class ControlMediaForApp : ActionData() {
abstract val packageName: String
+ abstract val savedAppName: String?
override fun compareTo(other: ActionData) = when (other) {
is ControlMediaForApp -> compareValuesBy(
@@ -344,42 +355,66 @@ sealed class ActionData : Comparable {
}
@Serializable
- data class Pause(override val packageName: String) : ControlMediaForApp() {
+ data class Pause(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.PAUSE_MEDIA_PACKAGE
}
@Serializable
- data class Play(override val packageName: String) : ControlMediaForApp() {
+ data class Play(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.PLAY_MEDIA_PACKAGE
}
@Serializable
- data class PlayPause(override val packageName: String) : ControlMediaForApp() {
+ data class PlayPause(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.PLAY_PAUSE_MEDIA_PACKAGE
}
@Serializable
- data class NextTrack(override val packageName: String) : ControlMediaForApp() {
+ data class NextTrack(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.NEXT_TRACK_PACKAGE
}
@Serializable
- data class PreviousTrack(override val packageName: String) : ControlMediaForApp() {
+ data class PreviousTrack(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.PREVIOUS_TRACK_PACKAGE
}
@Serializable
- data class FastForward(override val packageName: String) : ControlMediaForApp() {
+ data class FastForward(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.FAST_FORWARD_PACKAGE
}
@Serializable
- data class Rewind(override val packageName: String) : ControlMediaForApp() {
+ data class Rewind(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.REWIND_PACKAGE
}
@Serializable
- data class Stop(override val packageName: String) : ControlMediaForApp() {
+ data class Stop(
+ override val packageName: String,
+ override val savedAppName: String? = null,
+ ) : ControlMediaForApp() {
override val id = ActionId.STOP_MEDIA_PACKAGE
}
@@ -387,6 +422,7 @@ sealed class ActionData : Comparable {
data class StepForward(
override val packageName: String,
val stepDurationMs: Long? = null,
+ override val savedAppName: String? = null,
) : ControlMediaForApp() {
override val id = ActionId.STEP_FORWARD_PACKAGE
}
@@ -395,6 +431,7 @@ sealed class ActionData : Comparable {
data class StepBackward(
override val packageName: String,
val stepDurationMs: Long? = null,
+ override val savedAppName: String? = null,
) : ControlMediaForApp() {
override val id = ActionId.STEP_BACKWARD_PACKAGE
}
@@ -980,6 +1017,11 @@ sealed class ActionData : Comparable {
}
}
+ @Serializable
+ data class Vibrate(val effect: VibrateEffect) : ActionData() {
+ override val id: ActionId = ActionId.VIBRATE
+ }
+
@Serializable
data object AnswerCall : ActionData() {
override val id: ActionId = ActionId.ANSWER_PHONE_CALL
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 74ba3d8c49..90fc5e687f 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.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.ShellExecutionMode
import io.github.sds100.keymapper.common.utils.KMError
import io.github.sds100.keymapper.common.utils.KMResult
@@ -73,10 +74,15 @@ object ActionDataEntityMapper {
ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION
ActionEntity.Type.TOAST -> ActionId.TOAST
+
+ ActionEntity.Type.VIBRATE -> ActionId.VIBRATE
}
return when (actionId) {
- ActionId.APP -> ActionData.App(packageName = entity.data)
+ ActionId.APP -> ActionData.App(
+ packageName = entity.data,
+ savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(),
+ )
ActionId.APP_SHORTCUT -> {
val packageName =
@@ -90,6 +96,7 @@ object ActionDataEntityMapper {
packageName = packageName,
shortcutTitle = shortcutTitle,
uri = entity.data,
+ savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(),
)
}
@@ -448,36 +455,46 @@ object ActionDataEntityMapper {
ActionEntity.EXTRA_STEP_MEDIA_DURATION,
).valueOrNull()?.toLongOrNull()
+ val savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull()
+
when (actionId) {
ActionId.PAUSE_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.Pause(packageName)
+ ActionData.ControlMediaForApp.Pause(packageName, savedAppName)
ActionId.PLAY_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.Play(packageName)
+ ActionData.ControlMediaForApp.Play(packageName, savedAppName)
ActionId.PLAY_PAUSE_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.PlayPause(packageName)
+ ActionData.ControlMediaForApp.PlayPause(packageName, savedAppName)
ActionId.NEXT_TRACK_PACKAGE ->
- ActionData.ControlMediaForApp.NextTrack(packageName)
+ ActionData.ControlMediaForApp.NextTrack(packageName, savedAppName)
ActionId.PREVIOUS_TRACK_PACKAGE ->
- ActionData.ControlMediaForApp.PreviousTrack(packageName)
+ ActionData.ControlMediaForApp.PreviousTrack(packageName, savedAppName)
ActionId.FAST_FORWARD_PACKAGE ->
- ActionData.ControlMediaForApp.FastForward(packageName)
+ ActionData.ControlMediaForApp.FastForward(packageName, savedAppName)
ActionId.REWIND_PACKAGE ->
- ActionData.ControlMediaForApp.Rewind(packageName)
+ ActionData.ControlMediaForApp.Rewind(packageName, savedAppName)
ActionId.STOP_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.Stop(packageName)
+ ActionData.ControlMediaForApp.Stop(packageName, savedAppName)
ActionId.STEP_FORWARD_PACKAGE ->
- ActionData.ControlMediaForApp.StepForward(packageName, stepDurationMs)
+ ActionData.ControlMediaForApp.StepForward(
+ packageName,
+ stepDurationMs,
+ savedAppName,
+ )
ActionId.STEP_BACKWARD_PACKAGE ->
- ActionData.ControlMediaForApp.StepBackward(packageName, stepDurationMs)
+ ActionData.ControlMediaForApp.StepBackward(
+ packageName,
+ stepDurationMs,
+ savedAppName,
+ )
else -> throw Exception("don't know how to create system action for $actionId")
}
@@ -645,6 +662,8 @@ object ActionDataEntityMapper {
ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionData.CycleKeyboardLanguage
+ ActionId.CYCLE_KEYBOARD -> ActionData.CycleKeyboard
+
ActionId.TEXT_CUT -> ActionData.CutText
ActionId.TEXT_COPY -> ActionData.CopyText
@@ -723,6 +742,39 @@ object ActionDataEntityMapper {
)
}
+ ActionId.VIBRATE -> {
+ val mode = entity.extras.getData(ActionEntity.EXTRA_VIBRATE_MODE)
+ .valueOrNull() ?: return null
+
+ val effect = when (mode) {
+ ActionEntity.VIBRATE_MODE_DURATION -> {
+ val durationMs = entity.extras.getData(
+ ActionEntity.EXTRA_VIBRATE_DURATION_MS,
+ ).valueOrNull()?.toLongOrNull() ?: return null
+
+ VibrateEffect.CustomDuration(durationMs)
+ }
+
+ ActionEntity.VIBRATE_MODE_PREDEFINED -> {
+ val typeString = entity.extras.getData(
+ ActionEntity.EXTRA_VIBRATE_EFFECT_TYPE,
+ ).valueOrNull() ?: return null
+
+ val type = try {
+ VibrateEffect.PredefinedType.valueOf(typeString)
+ } catch (_: IllegalArgumentException) {
+ return null
+ }
+
+ VibrateEffect.Predefined(type)
+ }
+
+ else -> return null
+ }
+
+ ActionData.Vibrate(effect = effect)
+ }
+
ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall
ActionId.END_PHONE_CALL -> ActionData.EndCall
@@ -996,6 +1048,7 @@ object ActionDataEntityMapper {
is ActionData.ModifySetting -> ActionEntity.Type.MODIFY_SETTING
is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION
is ActionData.Toast -> ActionEntity.Type.TOAST
+ is ActionData.Vibrate -> ActionEntity.Type.VIBRATE
else -> ActionEntity.Type.SYSTEM_ACTION
}
@@ -1085,6 +1138,8 @@ object ActionDataEntityMapper {
is ActionData.Toast -> data.message
+ is ActionData.Vibrate -> ""
+
is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!!
is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!!
@@ -1134,11 +1189,14 @@ object ActionDataEntityMapper {
}
}.toList()
- is ActionData.App -> emptyList()
+ is ActionData.App -> listOfNotNull(
+ data.savedAppName?.let { EntityExtra(ActionEntity.EXTRA_APP_NAME, it) },
+ )
is ActionData.AppShortcut -> sequence {
yield(EntityExtra(ActionEntity.EXTRA_SHORTCUT_TITLE, data.shortcutTitle))
data.packageName?.let { yield(EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, it)) }
+ data.savedAppName?.let { yield(EntityExtra(ActionEntity.EXTRA_APP_NAME, it)) }
}.toList()
is ActionData.PhoneCall -> emptyList()
@@ -1168,6 +1226,7 @@ object ActionDataEntityMapper {
data.stepDurationMs?.let {
add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString()))
}
+ data.savedAppName?.let { add(EntityExtra(ActionEntity.EXTRA_APP_NAME, it)) }
}
is ActionData.ControlMediaForApp.StepBackward -> buildList {
@@ -1175,10 +1234,12 @@ object ActionDataEntityMapper {
data.stepDurationMs?.let {
add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString()))
}
+ data.savedAppName?.let { add(EntityExtra(ActionEntity.EXTRA_APP_NAME, it)) }
}
- is ActionData.ControlMediaForApp -> listOf(
+ is ActionData.ControlMediaForApp -> listOfNotNull(
EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, data.packageName),
+ data.savedAppName?.let { EntityExtra(ActionEntity.EXTRA_APP_NAME, it) },
)
is ActionData.ControlMedia.StepForward -> buildList {
@@ -1436,6 +1497,21 @@ object ActionDataEntityMapper {
EntityExtra(ActionEntity.EXTRA_TOAST_DURATION, data.duration.name),
)
+ is ActionData.Vibrate -> when (val effect = data.effect) {
+ is VibrateEffect.CustomDuration -> listOf(
+ EntityExtra(ActionEntity.EXTRA_VIBRATE_MODE, ActionEntity.VIBRATE_MODE_DURATION),
+ EntityExtra(
+ ActionEntity.EXTRA_VIBRATE_DURATION_MS,
+ effect.durationMs.toString(),
+ ),
+ )
+
+ is VibrateEffect.Predefined -> listOf(
+ EntityExtra(ActionEntity.EXTRA_VIBRATE_MODE, ActionEntity.VIBRATE_MODE_PREDEFINED),
+ EntityExtra(ActionEntity.EXTRA_VIBRATE_EFFECT_TYPE, effect.predefinedType.name),
+ )
+ }
+
is ActionData.TalkBackGesture -> listOf(
EntityExtra(ActionEntity.EXTRA_TALKBACK_GESTURE_TYPE, data.gesture.name),
)
@@ -1600,6 +1676,7 @@ object ActionDataEntityMapper {
ActionId.SWITCH_KEYBOARD to "switch_keyboard",
ActionId.CYCLE_KEYBOARD_LANGUAGE to "cycle_keyboard_language",
+ ActionId.CYCLE_KEYBOARD to "cycle_keyboard",
ActionId.TOGGLE_AIRPLANE_MODE to "toggle_airplane_mode",
ActionId.ENABLE_AIRPLANE_MODE to "enable_airplane_mode",
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt
index 6acbd9374d..df9ceb752e 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt
@@ -176,13 +176,13 @@ class LazyActionErrorSnapshot(
when (action) {
is ActionData.App -> {
- return getAppError(action.packageName)
+ return getAppError(action.packageName, action.savedAppName)
}
is ActionData.AppShortcut -> {
action.packageName ?: return null
- return getAppError(action.packageName)
+ return getAppError(action.packageName, action.savedAppName)
}
is ActionData.Sound.SoundFile -> {
@@ -272,14 +272,14 @@ class LazyActionErrorSnapshot(
return null
}
- private fun getAppError(packageName: String): KMError? {
+ private fun getAppError(packageName: String, appName: String?): KMError? {
if (isAppEnabledCache.contains(packageName) && isAppInstalledCache.contains(packageName)) {
if (isAppEnabledCache[packageName] == false) {
- return KMError.AppDisabled(packageName)
+ return KMError.AppDisabled(packageName, appName)
}
if (isAppInstalledCache[packageName] == false) {
- return KMError.AppDisabled(packageName)
+ return KMError.AppDisabled(packageName, appName)
}
return null
@@ -292,12 +292,12 @@ class LazyActionErrorSnapshot(
isAppEnabledCache[packageName] = isEnabled
if (!isEnabled) {
- return KMError.AppDisabled(packageName)
+ return KMError.AppDisabled(packageName, appName)
}
}
if (!isAppInstalled) {
- return KMError.AppNotFound(packageName)
+ return KMError.AppNotFound(packageName, appName)
}
return null
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 3dd5c08179..c892fbffc3 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
@@ -130,6 +130,7 @@ enum class ActionId {
SWITCH_KEYBOARD,
CYCLE_KEYBOARD_LANGUAGE,
+ CYCLE_KEYBOARD,
TOGGLE_AIRPLANE_MODE,
ENABLE_AIRPLANE_MODE,
@@ -151,6 +152,7 @@ enum class ActionId {
DISMISS_ALL_NOTIFICATIONS,
CREATE_NOTIFICATION,
TOAST,
+ VIBRATE,
ANSWER_PHONE_CALL,
END_PHONE_CALL,
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt
index c8493d9604..80be591ffb 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt
@@ -1,206 +1,390 @@
package io.github.sds100.keymapper.base.actions
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.draggable
-import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandIn
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ClearAll
+import androidx.compose.material.icons.outlined.ContentCopy
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Settings
-import androidx.compose.material.icons.rounded.Add
-import androidx.compose.material.icons.rounded.ArrowDownward
-import androidx.compose.material.icons.rounded.Clear
-import androidx.compose.material.icons.rounded.DragHandle
-import androidx.compose.material.icons.rounded.PlayCircleOutline
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ElevatedCard
-import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight
+import androidx.compose.material.icons.rounded.Repeat
+import androidx.compose.material.icons.rounded.TouchApp
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.CustomAccessibilityAction
-import androidx.compose.ui.semantics.customActions
-import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontStyle
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.utils.ui.LinkType
+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.ComposeIconInfo
import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState
+import io.github.sds100.keymapper.base.utils.ui.compose.EXPAND_ANIMATION_DURATION
+import io.github.sds100.keymapper.base.utils.ui.compose.ExpandableDraggableCard
+import io.github.sds100.keymapper.base.utils.ui.compose.horizontalFadingEdges
import io.github.sds100.keymapper.base.utils.ui.drawable
+/**
+ * The Material 3 alpha for disabled content.
+ */
+private const val DISABLED_ALPHA = 0.38f
+
+/**
+ * The summary fades out in the first half of the animation so it is invisible before the shrink
+ * clips it. When expanding, the space is made first and the text fades in during the second half.
+ */
+private val summaryEnterTransition: EnterTransition =
+ expandVertically(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.Top,
+ ) + fadeIn(
+ tween(
+ durationMillis = EXPAND_ANIMATION_DURATION / 2,
+ delayMillis = EXPAND_ANIMATION_DURATION / 2,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+
+private val summaryExitTransition: ExitTransition =
+ fadeOut(tween(EXPAND_ANIMATION_DURATION / 2, easing = FastOutSlowInEasing)) +
+ shrinkVertically(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ shrinkTowards = Alignment.Top,
+ )
+
+/**
+ * Animate both width and height so the title row does not jump when the rename button appears.
+ */
+private val renameButtonEnterTransition: EnterTransition =
+ fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) +
+ expandIn(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.CenterStart,
+ )
+
+private val renameButtonExitTransition: ExitTransition =
+ fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) +
+ shrinkOut(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ shrinkTowards = Alignment.CenterStart,
+ )
+
@Composable
fun ActionListItem(
modifier: Modifier = Modifier,
model: ActionListItemModel,
index: Int,
+ isExpanded: Boolean,
isDraggingEnabled: Boolean = false,
isDragging: Boolean,
isReorderingEnabled: Boolean,
dragDropState: DragDropState? = null,
+ onExpandedChange: (Boolean) -> Unit = {},
onEditClick: () -> Unit = {},
onRemoveClick: () -> Unit = {},
onFixClick: () -> Unit = {},
onTestClick: () -> Unit = {},
+ onDuplicateClick: () -> Unit = {},
+ onRenameClick: () -> Unit = {},
+ onEnabledChange: (Boolean) -> Unit = {},
onMoveUp: (() -> Unit)? = null,
onMoveDown: (() -> Unit)? = null,
) {
- val draggableState = rememberDraggableState {
- dragDropState?.onDrag(Offset(0f, it))
+ // Only grey out a disabled action when it is collapsed so the expanded options stay readable.
+ val contentAlpha by animateFloatAsState(
+ targetValue = if (model.isEnabled || isExpanded) 1f else DISABLED_ALPHA,
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ )
+
+ ExpandableDraggableCard(
+ modifier = modifier,
+ key = model.id,
+ isExpanded = isExpanded,
+ isDragging = isDragging,
+ isReorderingEnabled = isReorderingEnabled,
+ isDraggingEnabled = isDraggingEnabled,
+ dragDropState = dragDropState,
+ dragHandleContentDescription = stringResource(R.string.drag_handle_for, model.title),
+ expandContentDescription = stringResource(R.string.action_list_item_expand),
+ collapseContentDescription = stringResource(R.string.action_list_item_collapse),
+ contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
+ onExpandedChange = onExpandedChange,
+ onMoveUp = onMoveUp,
+ onMoveDown = onMoveDown,
+ headerContent = { expanded ->
+ ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon)
+
+ Spacer(Modifier.width(8.dp))
+
+ HeaderText(
+ modifier = Modifier.weight(1f).animateContentSize().padding(vertical = 8.dp),
+ model = model,
+ isExpanded = expanded,
+ onRenameClick = onRenameClick,
+ )
+ },
+ expandedContent = {
+ ExpandedContent(
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 8.dp,
+ bottom = 8.dp,
+ top = 8.dp,
+ ),
+ model = model,
+ onEditClick = onEditClick,
+ onRemoveClick = onRemoveClick,
+ onFixClick = onFixClick,
+ onTestClick = onTestClick,
+ onDuplicateClick = onDuplicateClick,
+ onEnabledChange = onEnabledChange,
+ )
+ },
+ )
+}
+
+@Composable
+private fun ActionIcon(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,
+ )
+ }
}
+}
- val moveUpLabel = stringResource(R.string.accessibility_action_move_up)
- val moveDownLabel = stringResource(R.string.accessibility_action_move_down)
+@Composable
+private fun HeaderText(
+ modifier: Modifier = Modifier,
+ model: ActionListItemModel,
+ isExpanded: Boolean,
+ onRenameClick: () -> Unit,
+) {
+ Column(modifier = modifier) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ modifier = Modifier
+ .weight(1f, fill = false),
+ text = model.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontStyle = if (model.isCustomName) FontStyle.Italic else FontStyle.Normal,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
- Column(modifier = modifier.fillMaxWidth()) {
- ElevatedCard(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = 48.dp)
- .height(IntrinsicSize.Min)
- .padding(start = 16.dp, end = 16.dp)
- .semantics {
- if (isReorderingEnabled) {
- customActions = buildList {
- onMoveUp?.let { action ->
- add(
- CustomAccessibilityAction(moveUpLabel) {
- action()
- true
- },
- )
- }
- onMoveDown?.let { action ->
- add(
- CustomAccessibilityAction(moveDownLabel) {
- action()
- true
- },
- )
- }
- }
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = renameButtonEnterTransition,
+ exit = renameButtonExitTransition,
+ ) {
+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 16.dp,
+ ) {
+ IconButton(onClick = onRenameClick) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = Icons.Rounded.Edit,
+ contentDescription = stringResource(R.string.action_list_item_rename),
+ )
}
- },
- colors = CardDefaults.elevatedCardColors(
- containerColor = if (isDragging) {
- MaterialTheme.colorScheme.surfaceContainerHighest
- } else {
- MaterialTheme.colorScheme.surfaceContainer
- },
- ),
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = !isExpanded,
+ enter = summaryEnterTransition,
+ exit = summaryExitTransition,
) {
- Row(
- modifier = Modifier.fillMaxSize(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Spacer(Modifier.width(8.dp))
-
- if (isReorderingEnabled) {
- Icon(
- modifier = Modifier
- .size(24.dp)
- .draggable(
- state = draggableState,
- enabled = isDraggingEnabled,
- orientation = Orientation.Vertical,
- startDragImmediately = true,
- onDragStarted = { offset ->
- dragDropState?.onDragStart(index, offset)
- },
- onDragStopped = { dragDropState?.onDragInterrupted() },
- ),
- imageVector = Icons.Rounded.DragHandle,
- contentDescription = stringResource(R.string.drag_handle_for, model.text),
- tint = MaterialTheme.colorScheme.onSurface,
+ Column {
+ if (model.summary != null) {
+ Text(
+ text = model.summary,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
)
}
- Spacer(Modifier.width(8.dp))
+ if (model.error != null && model.isEnabled) {
+ Text(
+ text = model.error,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+}
- if (model.error == null) {
- when (model.icon) {
- is ComposeIconInfo.Vector -> Icon(
- modifier = Modifier.size(24.dp),
- imageVector = model.icon.imageVector,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- )
+@Composable
+private fun ExpandedContent(
+ modifier: Modifier = Modifier,
+ model: ActionListItemModel,
+ onEditClick: () -> Unit,
+ onRemoveClick: () -> Unit,
+ onFixClick: () -> Unit,
+ onTestClick: () -> Unit,
+ onDuplicateClick: () -> Unit,
+ onEnabledChange: (Boolean) -> Unit,
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (model.error != null) {
+ Row(
+ modifier = Modifier.padding(end = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = model.error,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+
+ if (model.isErrorFixable) {
+ Spacer(Modifier.width(8.dp))
- is ComposeIconInfo.Drawable -> {
- val painter = rememberDrawablePainter(model.icon.drawable)
- Icon(
- modifier = Modifier.size(24.dp),
- painter = painter,
- contentDescription = null,
- tint = Color.Unspecified,
- )
- }
+ CompactErrorButton(onClick = onFixClick) {
+ Text(stringResource(R.string.button_fix))
}
}
+ }
+ }
- val primaryText = model.text
+ if (model.showRepeat) {
+ OptionRow(
+ modifier = Modifier
+ .fillMaxWidth(),
+ icon = Icons.Rounded.Repeat,
+ text = model.repeatText ?: stringResource(R.string.action_list_no_repeat),
+ isSet = model.repeatText != null,
+ )
+ }
- Spacer(Modifier.width(8.dp))
+ OptionRow(
+ modifier = Modifier
+ .fillMaxWidth(),
+ icon = Icons.Rounded.KeyboardDoubleArrowRight,
+ text = model.burstText ?: stringResource(R.string.action_list_no_burst),
+ isSet = model.burstText != null,
+ )
- TextColumn(
- modifier = Modifier
- .weight(1f)
- .padding(vertical = 8.dp),
- primaryText = primaryText,
- secondaryText = model.secondaryText,
- errorText = model.error,
- )
+ if (model.showHoldDown) {
+ OptionRow(
+ modifier = Modifier
+ .fillMaxWidth(),
+ icon = Icons.Rounded.TouchApp,
+ text = model.holdDownText ?: stringResource(R.string.action_list_no_hold_down),
+ isSet = model.holdDownText != null,
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.switch_enabled),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(Modifier.width(16.dp))
+
+ Switch(checked = model.isEnabled, onCheckedChange = onEnabledChange)
+
+ Spacer(Modifier.width(16.dp))
+
+ val scrollState = rememberScrollState()
+
+ // Scroll to the end initially so the delete button is always visible without
+ // needing to scroll.
+ LaunchedEffect(Unit) {
+ scrollState.scrollTo(scrollState.maxValue)
+ }
+
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .horizontalFadingEdges(scrollState)
+ .horizontalScroll(scrollState),
+ horizontalArrangement = Arrangement.End,
+ ) {
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 16.dp,
) {
- if (model.error != null && model.isErrorFixable) {
- FilledTonalButton(
- modifier = Modifier.padding(start = 8.dp, end = 8.dp),
- onClick = onFixClick,
- colors = ButtonDefaults.filledTonalButtonColors(
- containerColor = MaterialTheme.colorScheme.error,
- contentColor = MaterialTheme.colorScheme.onError,
+ IconButton(onClick = onDuplicateClick) {
+ Icon(
+ imageVector = Icons.Outlined.ContentCopy,
+ contentDescription = stringResource(
+ R.string.action_list_item_duplicate,
),
- ) {
- Text(
- text = stringResource(R.string.button_fix),
- )
- }
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
}
- if (model.error == null) {
- IconButton(onClick = onTestClick) {
- Icon(
- imageVector = Icons.Rounded.PlayCircleOutline,
- contentDescription = stringResource(R.string.action_list_item_test),
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp),
- )
- }
+ IconButton(onClick = onTestClick) {
+ Icon(
+ imageVector = Icons.Outlined.PlayArrow,
+ contentDescription = stringResource(R.string.action_list_item_test),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
}
IconButton(onClick = onEditClick) {
@@ -208,135 +392,213 @@ fun ActionListItem(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.action_list_item_edit),
tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp),
)
}
IconButton(onClick = onRemoveClick) {
Icon(
- imageVector = Icons.Rounded.Clear,
+ imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.action_list_item_remove),
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
-
- if (model.linkType == LinkType.HIDDEN) {
- // Important! Show an empty spacer so the height of the card remains constant
- // while dragging. If the height changes while dragging it can lead to janky
- // behavior.
- Spacer(Modifier.height(32.dp))
- } else {
- Spacer(Modifier.height(4.dp))
-
- Icon(
- imageVector = when (model.linkType) {
- LinkType.ARROW -> Icons.Rounded.ArrowDownward
- LinkType.PLUS -> Icons.Rounded.Add
- LinkType.HIDDEN -> Icons.Rounded.Add
- },
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier
- .size(24.dp)
- .align(Alignment.CenterHorizontally),
- )
- Spacer(Modifier.height(4.dp))
- }
}
}
@Composable
-private fun TextColumn(
+private fun OptionRow(
modifier: Modifier = Modifier,
- primaryText: String,
- secondaryText: String? = null,
- errorText: String? = null,
+ icon: ImageVector,
+ text: String,
+ isSet: Boolean,
) {
- Column(
+ val color = if (isSet) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ }
+
+ Row(
modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
) {
+ Icon(
+ modifier = Modifier.size(16.dp),
+ imageVector = icon,
+ contentDescription = null,
+ tint = color,
+ )
+
+ Spacer(Modifier.width(16.dp))
+
Text(
- text = primaryText,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = color,
)
- if (secondaryText != null) {
- Text(
- text = secondaryText,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- }
- if (errorText != null) {
- Text(
- text = errorText,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- )
- }
}
}
@Preview
@Composable
-private fun NoDragPreview() {
- ActionListItem(
- model = ActionListItemModel(
- id = "id",
- text = "Dismiss most recent notification",
- secondaryText = "Repeat until released",
- error = "Denied notification access permission",
- isErrorFixable = true,
- icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
- ),
- isDragging = false,
- isReorderingEnabled = false,
- index = 0,
- )
+private fun CollapsedErrorPreview() {
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Dismiss most recent notification",
+ summary = "Repeat until released",
+ error = "Denied notification access permission",
+ isErrorFixable = true,
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ ),
+ isExpanded = false,
+ isDragging = false,
+ isReorderingEnabled = true,
+ index = 0,
+ )
+ }
}
@Preview
@Composable
-private fun NoDragOneLinePreview() {
- ActionListItem(
- model = ActionListItemModel(
- id = "id",
- text = "Clear all",
- secondaryText = null,
- error = null,
- isErrorFixable = true,
- icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
- ),
- isDragging = false,
- isReorderingEnabled = false,
- index = 0,
- )
+private fun CollapsedErrorDisabledPreview() {
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Dismiss most recent notification",
+ summary = "Repeat until released",
+ error = "Denied notification access permission",
+ isErrorFixable = true,
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ isEnabled = false,
+ ),
+ isExpanded = false,
+ isDragging = false,
+ isReorderingEnabled = true,
+ index = 0,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun CollapsedOneLinePreview() {
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Clear all",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ ),
+ isExpanded = false,
+ isDragging = false,
+ isReorderingEnabled = false,
+ index = 0,
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun ExpandedPreview() {
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Open magnifier",
+ isCustomName = true,
+ error = "A Key Mapper keyboard must be enabled!",
+ isErrorFixable = true,
+ showRepeat = true,
+ repeatText = "Repeat 5x after 400ms every 50ms until pressed again",
+ showHoldDown = true,
+ burstText = null,
+ holdDownText = "Hold down for 1000ms",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ ),
+ isExpanded = true,
+ isDragging = false,
+ isReorderingEnabled = true,
+ index = 0,
+ )
+ }
+}
+
+@Preview(widthDp = 300)
+@Composable
+private fun ExpandedPreviewSmall() {
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Open magnifier",
+ isCustomName = true,
+ error = "A Key Mapper keyboard must be enabled!",
+ isErrorFixable = true,
+ showRepeat = true,
+ repeatText = "Repeat 5x after 400ms every 50ms until pressed again",
+ showHoldDown = true,
+ burstText = null,
+ holdDownText = "Hold down for 1000ms",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ ),
+ isExpanded = true,
+ isDragging = false,
+ isReorderingEnabled = true,
+ index = 0,
+ )
+ }
}
@Preview
@Composable
-private fun DragDrawablePreview() {
+private fun ExpandedNoErrorPreview() {
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Open magnifier",
+ isCustomName = true,
+ error = null,
+ isErrorFixable = true,
+ showRepeat = true,
+ repeatText = "Repeat 5x after 400ms every 50ms until pressed again",
+ showHoldDown = true,
+ burstText = null,
+ holdDownText = "Hold down for 1000ms",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ ),
+ isExpanded = true,
+ isDragging = false,
+ isReorderingEnabled = true,
+ index = 0,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun DisabledDrawablePreview() {
val drawable = LocalContext.current.drawable(R.mipmap.ic_launcher_round)
- ActionListItem(
- model = ActionListItemModel(
- id = "id",
- text = "Dismiss most recent notification",
- secondaryText = "Repeat until released",
- error = null,
- isErrorFixable = true,
- icon = ComposeIconInfo.Drawable(drawable),
- ),
- isDragging = false,
- isReorderingEnabled = true,
- index = 0,
- )
+ KeyMapperTheme {
+ ActionListItem(
+ model = ActionListItemModel(
+ id = "id",
+ title = "Dismiss most recent notification",
+ summary = "Repeat until released",
+ isEnabled = false,
+ icon = ComposeIconInfo.Drawable(drawable),
+ ),
+ isExpanded = false,
+ isDragging = false,
+ isReorderingEnabled = true,
+ index = 0,
+ )
+ }
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt
new file mode 100644
index 0000000000..633b9c249c
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt
@@ -0,0 +1,38 @@
+package io.github.sds100.keymapper.base.actions
+
+import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
+
+data class ActionListItemModel(
+ val id: String,
+ val icon: ComposeIconInfo,
+ val title: String,
+ val isExpanded: Boolean = false,
+ val isCustomName: Boolean = false,
+ val isEnabled: Boolean = true,
+ /**
+ * A summary of the options that is shown when the item is collapsed.
+ */
+ val summary: String? = null,
+ val error: String? = null,
+ val isErrorFixable: Boolean = true,
+ val showRepeat: Boolean = false,
+ /**
+ * Null if the action does not repeat.
+ */
+ val repeatText: String? = null,
+ /**
+ * Null if the action is not performed in a burst.
+ */
+ val burstText: String? = null,
+ val showHoldDown: Boolean = false,
+ /**
+ * Null if the action is not held down.
+ */
+ val holdDownText: String? = null,
+ /**
+ * Whether to show the chip for setting the delay before the next action. A delay after the
+ * last action does nothing.
+ */
+ val showDelayChip: Boolean = false,
+ val delayBeforeNextAction: Int? = null,
+)
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 34fcde0e91..df5d5eb93d 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
@@ -9,31 +9,43 @@ 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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.HelpOutline
+import androidx.compose.material.icons.rounded.Abc
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight
+import androidx.compose.material.icons.rounded.Repeat
+import androidx.compose.material.icons.rounded.TouchApp
import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.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.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
+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.R
@@ -41,9 +53,10 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme
import io.github.sds100.keymapper.base.utils.ui.SliderMaximums
import io.github.sds100.keymapper.base.utils.ui.SliderMinimums
import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes
-import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText
+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.base.utils.ui.compose.SliderOptionText
+import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog
import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe
import kotlinx.coroutines.launch
@@ -67,21 +80,53 @@ fun ActionOptionsBottomSheet(
val ctx = LocalContext.current
val helpUrl = stringResource(R.string.url_keymap_action_options_guide)
val scope = rememberCoroutineScope()
-
- @Suppress("ktlint:standard:max-line-length")
+ 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()) {
- Text(
- modifier = Modifier.align(Alignment.Center),
- textAlign = TextAlign.Center,
- text = stringResource(R.string.action_options_title),
- style = MaterialTheme.typography.headlineMedium,
- )
+ 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,
+ ),
+ )
+ }
+ }
IconButton(
modifier = Modifier
- .align(Alignment.TopEnd)
+ .align(Alignment.CenterEnd)
.padding(horizontal = 8.dp),
onClick = { uriHandler.openUriSafe(ctx, helpUrl) },
) {
@@ -92,190 +137,325 @@ fun ActionOptionsBottomSheet(
}
}
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OptionsHeaderRow(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ icon = state.actionTypeIcon,
+ text = stringResource(R.string.action_options_type_header, state.actionTypeTitle),
+ )
+
Spacer(modifier = Modifier.height(8.dp))
- Row(
+ FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (state.showEditButton) {
- OutlinedButton(
- modifier = Modifier.weight(1f),
- onClick = callback::onEditClick,
- ) {
- Text(stringResource(R.string.button_edit_action))
+ FilledTonalButton(onClick = callback::onEditClick) {
+ Text(stringResource(R.string.action_options_customize))
}
- Spacer(Modifier.width(16.dp))
}
- OutlinedButton(modifier = Modifier.weight(1f), onClick = callback::onReplaceClick) {
- Text(stringResource(R.string.button_replace_action))
+ FilledTonalButton(onClick = callback::onReplaceClick) {
+ Text(stringResource(R.string.action_options_swap))
}
}
if (state.showRepeat) {
- Spacer(Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(16.dp))
+ RepeatOptions(state = state, callback = callback)
+ }
- CheckBoxText(
- modifier = Modifier
- .padding(horizontal = 8.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.flag_repeat_actions),
- isChecked = state.isRepeatChecked,
- onCheckedChange = callback::onRepeatCheckedChange,
- )
+ 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)
}
- if (state.showRepeatRateWarning) {
- Spacer(Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(16.dp))
- Row(
- verticalAlignment = Alignment.CenterVertically,
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Button(
+ onClick = {
+ scope.launch {
+ sheetState.hide()
+ onDismissRequest()
+ }
+ },
) {
- Spacer(Modifier.width(16.dp))
Icon(
- Icons.Rounded.Warning,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ imageVector = Icons.Rounded.Check,
contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- )
- Spacer(Modifier.width(8.dp))
- Text(
- modifier = Modifier.weight(1f),
- text = stringResource(R.string.action_repeat_rate_warning),
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.labelLarge,
)
- Spacer(Modifier.width(16.dp))
+ Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
+ Text(stringResource(R.string.button_done))
}
}
- if (state.showRepeatRate) {
- Spacer(Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
- SliderOptionText(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- title = stringResource(R.string.extra_label_repeat_rate),
- defaultValue = state.defaultRepeatRate.toFloat(),
- value = state.repeatRate.toFloat(),
- valueText = { "${it.toInt()} ms" },
- onValueChange = { callback.onRepeatRateChanged(it.toInt()) },
- valueRange = 0f..SliderMaximums.ACTION_REPEAT_RATE.toFloat(),
- stepSize = SliderStepSizes.ACTION_REPEAT_RATE,
- )
- }
+@Composable
+private fun RepeatOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) {
+ Column {
+ OptionsHeaderRow(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ icon = Icons.Rounded.Repeat,
+ text = stringResource(R.string.action_options_repeat_header),
+ )
- if (state.showRepeatLimit) {
- Spacer(Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(8.dp))
- val noLimitString = stringResource(R.string.button_slider_repeat_no_limit)
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ text = stringResource(R.string.action_options_repeat_description),
+ style = MaterialTheme.typography.bodyMedium,
+ )
- SliderOptionText(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- title = stringResource(R.string.extra_label_repeat_limit),
- defaultValue = state.defaultRepeatLimit.toFloat(),
- value = state.repeatLimit.toFloat(),
- valueText = { value ->
- if (value.toInt() == Int.MAX_VALUE) {
- noLimitString
- } else {
- "${value.toInt()}x"
- }
- },
- onValueChange = { callback.onRepeatLimitChanged(it.toInt()) },
- valueRange = 1f..SliderMaximums.ACTION_REPEAT_LIMIT.toFloat(),
- stepSize = SliderStepSizes.ACTION_REPEAT_LIMIT,
- )
- }
+ val selectedMode: RepeatMode? = if (state.isRepeatChecked) {
+ state.repeatMode
+ } else {
+ null
+ }
- if (state.showRepeatDelay) {
- Spacer(Modifier.height(8.dp))
+ RadioButtonText(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_dont_repeat),
+ isSelected = selectedMode == null,
+ onSelected = { callback.onSelectRepeatMode(null) },
+ )
- SliderOptionText(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- title = stringResource(R.string.extra_label_repeat_delay),
- defaultValue = state.defaultRepeatDelay.toFloat(),
- value = state.repeatDelay.toFloat(),
- valueText = { "${it.toInt()} ms" },
- onValueChange = { callback.onRepeatDelayChanged(it.toInt()) },
- valueRange = 0f..SliderMaximums.ACTION_REPEAT_DELAY.toFloat(),
- stepSize = SliderStepSizes.ACTION_REPEAT_DELAY,
- )
- }
+ RadioButtonText(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_repeat_limit_reached),
+ isSelected = selectedMode == RepeatMode.LIMIT_REACHED,
+ isEnabled = state.allowedRepeatModes.contains(RepeatMode.LIMIT_REACHED),
+ onSelected = { callback.onSelectRepeatMode(RepeatMode.LIMIT_REACHED) },
+ )
+
+ val isUntilReleasedAllowed =
+ state.allowedRepeatModes.contains(RepeatMode.TRIGGER_RELEASED)
+
+ RadioButtonText(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_repeat_until_released),
+ isSelected = selectedMode == RepeatMode.TRIGGER_RELEASED,
+ isEnabled = isUntilReleasedAllowed,
+ onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) },
+ )
+
+ if (!isUntilReleasedAllowed) {
+ Text(
+ // Align with the text of the radio button.
+ modifier = Modifier.padding(start = 48.dp, end = 16.dp),
+ text = stringResource(R.string.action_options_repeat_until_released_unavailable),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
+
+ RadioButtonText(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_repeat_until_pressed_again),
+ isSelected = selectedMode == RepeatMode.TRIGGER_PRESSED_AGAIN,
+ isEnabled = state.allowedRepeatModes.contains(RepeatMode.TRIGGER_PRESSED_AGAIN),
+ onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) },
+ )
- if (state.allowedRepeatModes.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
+ if (state.showRepeatRateWarning) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Rounded.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(R.string.stop_repeating_dot_dot_dot),
- style = MaterialTheme.typography.titleSmall,
+ text = stringResource(R.string.action_repeat_rate_warning),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.labelMedium,
)
+ }
+ }
- FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- if (state.allowedRepeatModes.contains(RepeatMode.TRIGGER_RELEASED)) {
- RadioButtonText(
- isSelected = state.repeatMode == RepeatMode.TRIGGER_RELEASED,
- text = stringResource(R.string.stop_repeating_when_trigger_released),
- onSelected = {
- callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED)
- },
- )
- }
+ if (state.showRepeatDelay) {
+ Spacer(modifier = Modifier.height(8.dp))
- if (state.allowedRepeatModes.contains(RepeatMode.TRIGGER_PRESSED_AGAIN)) {
- RadioButtonText(
- isSelected = state.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN,
- text = stringResource(R.string.stop_repeating_trigger_pressed_again),
- onSelected = {
- callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN)
- },
- )
- }
+ SliderOptionText(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ title = stringResource(R.string.action_options_repeat_delay),
+ defaultValue = state.defaultRepeatDelay.toFloat(),
+ value = state.repeatDelay.toFloat(),
+ valueText = { "${it.toInt()} ms" },
+ onValueChange = { callback.onRepeatDelayChanged(it.toInt()) },
+ valueRange = 0f..SliderMaximums.ACTION_REPEAT_DELAY.toFloat(),
+ stepSize = SliderStepSizes.ACTION_REPEAT_DELAY,
+ )
+ }
- if (state.allowedRepeatModes.contains(RepeatMode.LIMIT_REACHED)) {
- RadioButtonText(
- isSelected = state.repeatMode == RepeatMode.LIMIT_REACHED,
- text = stringResource(R.string.stop_repeating_limit_reached),
- onSelected = { callback.onSelectRepeatMode(RepeatMode.LIMIT_REACHED) },
- )
+ if (state.showRepeatRate) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ SliderOptionText(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ title = stringResource(R.string.action_options_repeat_rate),
+ defaultValue = state.defaultRepeatRate.toFloat(),
+ value = state.repeatRate.toFloat(),
+ valueText = { "${it.toInt()} ms" },
+ onValueChange = { callback.onRepeatRateChanged(it.toInt()) },
+ valueRange = 0f..SliderMaximums.ACTION_REPEAT_RATE.toFloat(),
+ stepSize = SliderStepSizes.ACTION_REPEAT_RATE,
+ )
+ }
+
+ if (state.showRepeatLimit) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val noLimitString = stringResource(R.string.button_slider_repeat_no_limit)
+
+ SliderOptionText(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ title = stringResource(R.string.action_options_repeat_limit),
+ defaultValue = state.defaultRepeatLimit.toFloat(),
+ value = state.repeatLimit.toFloat(),
+ valueText = { value ->
+ if (value.toInt() == Int.MAX_VALUE) {
+ noLimitString
+ } else {
+ "${value.toInt()}x"
}
+ },
+ onValueChange = { callback.onRepeatLimitChanged(it.toInt()) },
+ valueRange = 1f..SliderMaximums.ACTION_REPEAT_LIMIT.toFloat(),
+ stepSize = SliderStepSizes.ACTION_REPEAT_LIMIT,
+ )
+ }
+ }
+}
- Spacer(Modifier.width(8.dp))
- }
- }
+@Composable
+private fun BurstOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) {
+ Column {
+ OptionsHeaderRow(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ icon = Icons.Rounded.KeyboardDoubleArrowRight,
+ text = stringResource(R.string.action_options_burst_header),
+ )
- if (state.showRepeat) {
- Spacer(Modifier.height(8.dp))
- HorizontalDivider()
- }
+ Spacer(modifier = Modifier.height(8.dp))
- if (state.showHoldDown) {
- Spacer(Modifier.height(8.dp))
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ text = if (state.showRepeat && state.isRepeatChecked) {
+ stringResource(R.string.action_options_burst_description_repeat)
+ } else {
+ stringResource(R.string.action_options_burst_description)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ )
- CheckBoxText(
- modifier = Modifier
- .padding(horizontal = 8.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.flag_hold_down),
- isChecked = state.isHoldDownChecked,
- onCheckedChange = callback::onHoldDownCheckedChange,
- )
- }
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val multiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat()
+ val multiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat()
+ SliderOptionText(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ title = stringResource(R.string.action_options_burst_size),
+ defaultValue = state.defaultMultiplier.toFloat(),
+ value = state.multiplier.toFloat(),
+ valueText = { "${it.toInt()}x" },
+ onValueChange = { callback.onMultiplierChanged(it.toInt()) },
+ valueRange = multiplierMin..multiplierMax,
+ stepSize = SliderStepSizes.ACTION_MULTIPLIER,
+ )
+ }
+}
+
+@Composable
+private fun HoldDownOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) {
+ val isRepeating = state.showRepeat && state.isRepeatChecked
+
+ Column {
+ OptionsHeaderRow(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ icon = Icons.Rounded.TouchApp,
+ text = stringResource(R.string.action_options_hold_down_header),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ text = if (isRepeating) {
+ stringResource(R.string.action_options_hold_down_description_repeat)
+ } else {
+ stringResource(R.string.action_options_hold_down_description)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ )
+
+ val selectedMode: HoldDownMode? = if (state.isHoldDownChecked) {
+ state.holdDownMode
+ } else {
+ null
+ }
+
+ RadioButtonText(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_dont_hold_down),
+ isSelected = selectedMode == null,
+ onSelected = { callback.onSelectHoldDownMode(null) },
+ )
+
+ if (isRepeating) {
+ // When repeating, the action is held down for a period of time before each repeat.
+ RadioButtonText(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_hold_down_period),
+ isSelected = selectedMode != null,
+ onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) },
+ )
if (state.showHoldDownDuration) {
- Spacer(Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(8.dp))
val holdDownDurationMin = SliderMinimums.ACTION_HOLD_DOWN_DURATION.toFloat()
val holdDownDurationMax = SliderMaximums.ACTION_HOLD_DOWN_DURATION.toFloat()
@@ -292,123 +472,26 @@ fun ActionOptionsBottomSheet(
stepSize = SliderStepSizes.ACTION_HOLD_DOWN_DURATION,
)
}
-
- if (state.showHoldDownMode) {
- Spacer(Modifier.height(8.dp))
-
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(R.string.hold_down_until_trigger_is_dot_dot_dot),
- style = MaterialTheme.typography.titleSmall,
- )
-
- FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- RadioButtonText(
- isSelected = state.holdDownMode == HoldDownMode.TRIGGER_RELEASED,
- text = stringResource(R.string.stop_holding_down_when_trigger_released),
- onSelected = {
- callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED)
- },
- )
-
- RadioButtonText(
- isSelected = state.holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN,
- text = stringResource(R.string.stop_holding_down_trigger_pressed_again),
- onSelected = {
- callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN)
- },
- )
-
- Spacer(Modifier.width(8.dp))
- }
- }
-
- if (state.showHoldDown) {
- Spacer(Modifier.height(8.dp))
- HorizontalDivider()
- }
-
- if (state.showDelayBeforeNextAction) {
- Spacer(Modifier.height(8.dp))
-
- val delayBeforeNextActionMin =
- SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat()
- val delayBeforeNextActionMax =
- SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat()
- SliderOptionText(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- title = stringResource(R.string.extra_label_delay_before_next_action),
- defaultValue = state.defaultDelayBeforeNextAction.toFloat(),
- value = state.delayBeforeNextAction.toFloat(),
- valueText = { "${it.toInt()} ms" },
- onValueChange = { callback.onDelayBeforeNextActionChanged(it.toInt()) },
- valueRange = delayBeforeNextActionMin..delayBeforeNextActionMax,
- stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION,
- )
- }
-
- Spacer(Modifier.height(8.dp))
-
- val actionMultiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat()
- val actionMultiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat()
- SliderOptionText(
+ } else {
+ RadioButtonText(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- title = if (state.showRepeat && state.isRepeatChecked) {
- stringResource(R.string.extra_label_action_multiplier_with_repeat)
- } else {
- stringResource(R.string.extra_label_action_multiplier)
- },
- defaultValue = state.defaultMultiplier.toFloat(),
- value = state.multiplier.toFloat(),
- valueText = { "${it.toInt()}x" },
- onValueChange = { callback.onMultiplierChanged(it.toInt()) },
- valueRange = actionMultiplierMin..actionMultiplierMax,
- stepSize = SliderStepSizes.ACTION_MULTIPLIER,
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_hold_down_until_released),
+ isSelected = selectedMode == HoldDownMode.TRIGGER_RELEASED,
+ onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) },
)
- Spacer(Modifier.height(8.dp))
-
- HorizontalDivider()
-
- Spacer(Modifier.height(8.dp))
-
- Row(
+ RadioButtonText(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- ) {
- Spacer(Modifier.weight(0.5f))
- Spacer(Modifier.width(16.dp))
-
- FilledTonalButton(
- modifier = Modifier.weight(0.5f),
- colors = ButtonDefaults.filledTonalButtonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- ),
- onClick = {
- scope.launch {
- sheetState.hide()
- onDismissRequest()
- }
- },
- ) {
- Spacer(Modifier.width(16.dp))
- Text(stringResource(R.string.button_done))
- Spacer(Modifier.width(16.dp))
- }
- }
-
- Spacer(Modifier.height(16.dp))
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth(),
+ text = stringResource(R.string.action_options_hold_down_until_pressed_again),
+ isSelected = selectedMode == HoldDownMode.TRIGGER_PRESSED_AGAIN,
+ onSelected = {
+ callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN)
+ },
+ )
}
}
}
@@ -416,20 +499,68 @@ fun ActionOptionsBottomSheet(
interface ActionOptionsBottomSheetCallback {
fun onEditClick() = run { }
fun onReplaceClick() = run { }
- fun onRepeatCheckedChange(checked: Boolean) = run { }
- fun onSelectRepeatMode(repeatMode: RepeatMode) = run { }
+ fun onCustomNameChanged(name: String) = run { }
+
+ /**
+ * @param repeatMode null if the action should not repeat.
+ */
+ fun onSelectRepeatMode(repeatMode: RepeatMode?) = run { }
fun onRepeatRateChanged(rate: Int) = run { }
fun onRepeatLimitChanged(limit: Int) = run { }
fun onRepeatDelayChanged(delay: Int) = run { }
- fun onHoldDownCheckedChange(checked: Boolean) = run { }
+
+ /**
+ * @param holdDownMode null if the action should not be held down.
+ */
+ fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) = run { }
fun onHoldDownDurationChanged(duration: Int) = run { }
- fun onSelectHoldDownMode(holdDownMode: HoldDownMode) = run { }
- fun onDelayBeforeNextActionChanged(delay: Int) = run { }
fun onMultiplierChanged(multiplier: Int) = run { }
}
+private val previewState = ActionOptionsState(
+ title = "Input KEYCODE_0",
+ actionTypeTitle = "Input key event",
+ actionTypeIcon = Icons.Rounded.Abc,
+
+ showEditButton = true,
+ showRepeat = true,
+ isRepeatChecked = true,
+ showRepeatRateWarning = false,
+
+ showRepeatRate = true,
+ repeatRate = 400,
+ defaultRepeatRate = 500,
+
+ showRepeatDelay = true,
+ repeatDelay = 400,
+ defaultRepeatDelay = 400,
+
+ showRepeatLimit = true,
+ repeatLimit = Int.MAX_VALUE,
+ defaultRepeatLimit = Int.MAX_VALUE,
+
+ allowedRepeatModes = setOf(
+ RepeatMode.TRIGGER_RELEASED,
+ RepeatMode.LIMIT_REACHED,
+ RepeatMode.TRIGGER_PRESSED_AGAIN,
+ ),
+ repeatMode = RepeatMode.TRIGGER_PRESSED_AGAIN,
+
+ showHoldDown = true,
+ isHoldDownChecked = true,
+
+ showHoldDownDuration = true,
+ holdDownDuration = 400,
+ defaultHoldDownDuration = 400,
+
+ holdDownMode = HoldDownMode.TRIGGER_RELEASED,
+
+ multiplier = 4,
+ defaultMultiplier = 1,
+)
+
@OptIn(ExperimentalMaterial3Api::class)
-@Preview(heightDp = 1000)
+@Preview(heightDp = 1600, showSystemUi = true)
@Composable
private fun Preview() {
KeyMapperTheme {
@@ -437,111 +568,67 @@ private fun Preview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ActionOptionsBottomSheet(
sheetState = sheetState,
- state = ActionOptionsState(
- showEditButton = true,
- showRepeat = true,
- isRepeatChecked = true,
- showRepeatRateWarning = true,
-
- showRepeatRate = true,
- repeatRate = 400,
- defaultRepeatRate = 500,
-
- showRepeatDelay = true,
- repeatDelay = 400,
- defaultRepeatDelay = 400,
-
- showRepeatLimit = true,
- repeatLimit = Int.MAX_VALUE,
- defaultRepeatLimit = Int.MAX_VALUE,
-
- allowedRepeatModes = setOf(
- RepeatMode.TRIGGER_RELEASED,
- RepeatMode.LIMIT_REACHED,
- RepeatMode.TRIGGER_PRESSED_AGAIN,
- ),
- repeatMode = RepeatMode.TRIGGER_RELEASED,
-
- showHoldDown = true,
- isHoldDownChecked = false,
-
- showHoldDownDuration = true,
- holdDownDuration = 400,
- defaultHoldDownDuration = 400,
-
- showHoldDownMode = true,
- holdDownMode = HoldDownMode.TRIGGER_PRESSED_AGAIN,
-
- showDelayBeforeNextAction = true,
- delayBeforeNextAction = 10000,
- defaultDelayBeforeNextAction = 5000,
-
- multiplier = 4,
- defaultMultiplier = 1,
- ),
+ state = previewState,
callback = object : ActionOptionsBottomSheetCallback {},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(heightDp = 1600, showSystemUi = true)
@Composable
-private fun PreviewNoEditButton() {
+private fun PreviewUntilReleasedUnavailable() {
KeyMapperTheme {
val sheetState = SheetState(
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ActionOptionsBottomSheet(
sheetState = sheetState,
- state = ActionOptionsState(
+ state = previewState.copy(
showEditButton = false,
- showRepeat = true,
- isRepeatChecked = true,
showRepeatRateWarning = true,
-
- showRepeatRate = true,
- repeatRate = 400,
- defaultRepeatRate = 500,
-
- showRepeatDelay = true,
- repeatDelay = 400,
- defaultRepeatDelay = 400,
-
- showRepeatLimit = true,
- repeatLimit = 10,
- defaultRepeatLimit = Int.MAX_VALUE,
-
allowedRepeatModes = setOf(
- RepeatMode.TRIGGER_RELEASED,
RepeatMode.LIMIT_REACHED,
RepeatMode.TRIGGER_PRESSED_AGAIN,
),
- repeatMode = RepeatMode.TRIGGER_RELEASED,
-
- showHoldDown = true,
isHoldDownChecked = false,
+ ),
+ callback = object : ActionOptionsBottomSheetCallback {},
+ )
+ }
+}
- showHoldDownDuration = true,
- holdDownDuration = 400,
- defaultHoldDownDuration = 400,
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(heightDp = 1600, showSystemUi = true)
+@Composable
+private fun PreviewNotRepeating() {
+ KeyMapperTheme {
+ val sheetState = SheetState(
+ skipPartiallyExpanded = true,
+ positionalThreshold = { 0f },
+ velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
+ skipHiddenState = true,
+ )
- showHoldDownMode = true,
+ ActionOptionsBottomSheet(
+ sheetState = sheetState,
+ state = previewState.copy(
+ isRepeatChecked = false,
+ showRepeatRate = false,
+ showRepeatDelay = false,
+ showRepeatLimit = false,
+ showHoldDownDuration = false,
holdDownMode = HoldDownMode.TRIGGER_PRESSED_AGAIN,
-
- showDelayBeforeNextAction = true,
- delayBeforeNextAction = 10000,
- defaultDelayBeforeNextAction = 5000,
-
- multiplier = 4,
- defaultMultiplier = 1,
),
callback = object : ActionOptionsBottomSheetCallback {},
)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt
new file mode 100644
index 0000000000..525005f0ef
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt
@@ -0,0 +1,42 @@
+package io.github.sds100.keymapper.base.actions
+
+import androidx.compose.ui.graphics.vector.ImageVector
+
+data class ActionOptionsState(
+ val title: String,
+ val actionTypeTitle: String,
+ val actionTypeIcon: ImageVector,
+
+ val showEditButton: Boolean,
+
+ val showRepeat: Boolean,
+ val isRepeatChecked: Boolean,
+
+ val showRepeatRate: Boolean,
+ val showRepeatRateWarning: Boolean,
+ val repeatRate: Int,
+ val defaultRepeatRate: Int,
+
+ val showRepeatDelay: Boolean,
+ val repeatDelay: Int,
+ val defaultRepeatDelay: Int,
+
+ val showRepeatLimit: Boolean,
+ val repeatLimit: Int,
+ val defaultRepeatLimit: Int,
+
+ val allowedRepeatModes: Set,
+ val repeatMode: RepeatMode,
+
+ val showHoldDown: Boolean,
+ val isHoldDownChecked: Boolean,
+
+ val showHoldDownDuration: Boolean,
+ val holdDownDuration: Int,
+ val defaultHoldDownDuration: Int,
+
+ val holdDownMode: HoldDownMode,
+
+ val multiplier: Int,
+ val defaultMultiplier: Int,
+)
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 cebf056bd6..6e9e1f0306 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
@@ -10,11 +10,13 @@ import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.base.utils.DndModeStrings
import io.github.sds100.keymapper.base.utils.KeyCodeStrings
import io.github.sds100.keymapper.base.utils.RingerModeStrings
+import io.github.sds100.keymapper.base.utils.VibrateEffectStrings
import io.github.sds100.keymapper.base.utils.VolumeStreamStrings
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.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.ShellExecutionMode
import io.github.sds100.keymapper.common.utils.InputDeviceUtils
import io.github.sds100.keymapper.common.utils.Orientation
@@ -22,6 +24,7 @@ import io.github.sds100.keymapper.common.utils.PinchScreenType
import io.github.sds100.keymapper.common.utils.handle
import io.github.sds100.keymapper.common.utils.hasFlag
import io.github.sds100.keymapper.common.utils.toPercentString
+import io.github.sds100.keymapper.common.utils.valueIfFailure
import io.github.sds100.keymapper.system.camera.CameraLens
import io.github.sds100.keymapper.system.intents.IntentTarget
@@ -31,12 +34,22 @@ class ActionUiHelper(
) : ResourceProvider by resourceProvider,
DisplayActionUseCase by displayActionUseCase {
+ /**
+ * @return the custom name of the action if it has one, otherwise the generated title.
+ */
+ fun getTitle(action: Action, showDeviceDescriptors: Boolean): String {
+ return action.customName?.takeIf { it.isNotBlank() }
+ ?: getTitle(action.data, showDeviceDescriptors)
+ }
+
fun getTitle(action: ActionData, showDeviceDescriptors: Boolean): String = when (action) {
- is ActionData.App ->
- getAppName(action.packageName).handle(
- onSuccess = { getString(R.string.description_open_app, it) },
- onError = { getString(R.string.description_open_app, action.packageName) },
- )
+ is ActionData.App -> {
+ val name = getAppName(action.packageName).valueIfFailure {
+ action.savedAppName ?: action.packageName
+ }
+
+ getString(R.string.description_open_app, name)
+ }
is ActionData.AppShortcut -> action.shortcutTitle
@@ -204,95 +217,61 @@ class ActionUiHelper(
}
}
- is ActionData.ControlMediaForApp ->
- getAppName(action.packageName).handle(
- onSuccess = { appName ->
- if (action is ActionData.ControlMediaForApp.StepForward &&
- action.stepDurationMs != null
- ) {
- getString(
- R.string.action_step_forward_media_package_with_duration_formatted,
- arrayOf(appName, (action.stepDurationMs / 1000).toInt()),
- )
- } else if (action is ActionData.ControlMediaForApp.StepBackward &&
- action.stepDurationMs != null
- ) {
- getString(
- R.string.action_step_backward_media_package_with_duration_formatted,
- arrayOf(appName, (action.stepDurationMs / 1000).toInt()),
- )
- } else {
- val resId = when (action) {
- is ActionData.ControlMediaForApp.Play ->
- R.string.action_play_media_package_formatted
-
- is ActionData.ControlMediaForApp.FastForward ->
- R.string.action_fast_forward_package_formatted
-
- is ActionData.ControlMediaForApp.NextTrack ->
- R.string.action_next_track_package_formatted
-
- is ActionData.ControlMediaForApp.Pause ->
- R.string.action_pause_media_package_formatted
-
- is ActionData.ControlMediaForApp.PlayPause ->
- R.string.action_play_pause_media_package_formatted
-
- is ActionData.ControlMediaForApp.PreviousTrack ->
- R.string.action_previous_track_package_formatted
-
- is ActionData.ControlMediaForApp.Rewind ->
- R.string.action_rewind_package_formatted
-
- is ActionData.ControlMediaForApp.Stop ->
- R.string.action_stop_media_package_formatted
-
- is ActionData.ControlMediaForApp.StepForward ->
- R.string.action_step_forward_media_package_formatted
-
- is ActionData.ControlMediaForApp.StepBackward ->
- R.string.action_step_backward_media_package_formatted
- }
+ is ActionData.ControlMediaForApp -> {
+ val appName = getAppName(action.packageName).valueIfFailure {
+ action.savedAppName ?: action.packageName
+ }
- getString(resId, appName)
- }
- },
- onError = {
- val resId = when (action) {
- is ActionData.ControlMediaForApp.Play ->
- R.string.action_play_media_package
+ if (action is ActionData.ControlMediaForApp.StepForward &&
+ action.stepDurationMs != null
+ ) {
+ getString(
+ R.string.action_step_forward_media_package_with_duration_formatted,
+ arrayOf(appName, (action.stepDurationMs / 1000).toInt()),
+ )
+ } else if (action is ActionData.ControlMediaForApp.StepBackward &&
+ action.stepDurationMs != null
+ ) {
+ getString(
+ R.string.action_step_backward_media_package_with_duration_formatted,
+ arrayOf(appName, (action.stepDurationMs / 1000).toInt()),
+ )
+ } else {
+ val resId = when (action) {
+ is ActionData.ControlMediaForApp.Play ->
+ R.string.action_play_media_package_formatted
- is ActionData.ControlMediaForApp.FastForward ->
- R.string.action_fast_forward_package
+ is ActionData.ControlMediaForApp.FastForward ->
+ R.string.action_fast_forward_package_formatted
- is ActionData.ControlMediaForApp.NextTrack ->
- R.string.action_next_track_package
+ is ActionData.ControlMediaForApp.NextTrack ->
+ R.string.action_next_track_package_formatted
- is ActionData.ControlMediaForApp.Pause ->
- R.string.action_pause_media_package
+ is ActionData.ControlMediaForApp.Pause ->
+ R.string.action_pause_media_package_formatted
- is ActionData.ControlMediaForApp.PlayPause ->
- R.string.action_play_pause_media_package
+ is ActionData.ControlMediaForApp.PlayPause ->
+ R.string.action_play_pause_media_package_formatted
- is ActionData.ControlMediaForApp.PreviousTrack ->
- R.string.action_previous_track_package
+ is ActionData.ControlMediaForApp.PreviousTrack ->
+ R.string.action_previous_track_package_formatted
- is ActionData.ControlMediaForApp.Rewind ->
- R.string.action_rewind_package
+ is ActionData.ControlMediaForApp.Rewind ->
+ R.string.action_rewind_package_formatted
- is ActionData.ControlMediaForApp.Stop ->
- R.string.action_stop_media_package
+ is ActionData.ControlMediaForApp.Stop ->
+ R.string.action_stop_media_package_formatted
- is ActionData.ControlMediaForApp.StepForward ->
- R.string.action_step_forward_media_package
+ is ActionData.ControlMediaForApp.StepForward ->
+ R.string.action_step_forward_media_package_formatted
- is ActionData.ControlMediaForApp.StepBackward ->
- R.string.action_step_backward_media_package
- }
+ is ActionData.ControlMediaForApp.StepBackward ->
+ R.string.action_step_backward_media_package_formatted
+ }
- getString(resId)
- },
- )
+ getString(resId, appName)
+ }
+ }
is ActionData.Flashlight -> {
when (action) {
@@ -712,18 +691,24 @@ class ActionUiHelper(
R.string.action_expand_notification_drawer,
)
- ActionData.StatusBar.ExpandQuickSettings -> getString(R.string.action_expand_quick_settings)
+ ActionData.StatusBar.ExpandQuickSettings -> getString(
+ R.string.action_expand_quick_settings,
+ )
ActionData.StatusBar.ToggleNotifications -> getString(
R.string.action_toggle_notification_drawer,
)
- ActionData.StatusBar.ToggleQuickSettings -> getString(R.string.action_toggle_quick_settings)
+ ActionData.StatusBar.ToggleQuickSettings -> getString(
+ R.string.action_toggle_quick_settings,
+ )
ActionData.ToggleKeyboard -> getString(R.string.action_toggle_keyboard)
ActionData.CycleKeyboardLanguage -> getString(R.string.action_cycle_keyboard_language)
+ ActionData.CycleKeyboard -> getString(R.string.action_cycle_keyboard)
+
ActionData.ToggleSplitScreen -> getString(R.string.action_toggle_split_screen)
ActionData.VoiceAssistant -> getString(R.string.action_open_assistant)
@@ -734,7 +719,9 @@ class ActionUiHelper(
ActionData.Wifi.Toggle -> getString(R.string.action_toggle_wifi)
- ActionData.DismissAllNotifications -> getString(R.string.action_dismiss_all_notifications)
+ ActionData.DismissAllNotifications -> getString(
+ R.string.action_dismiss_all_notifications,
+ )
ActionData.DismissLastNotification -> getString(
R.string.action_dismiss_most_recent_notification,
@@ -813,6 +800,24 @@ class ActionUiHelper(
}
}
+ is ActionData.Vibrate -> {
+ when (val effect = action.effect) {
+ is VibrateEffect.CustomDuration -> {
+ getString(
+ R.string.action_vibrate_description_duration,
+ effect.durationMs.toString(),
+ )
+ }
+
+ is VibrateEffect.Predefined -> {
+ getString(
+ R.string.action_vibrate_description_predefined,
+ getString(VibrateEffectStrings.getLabel(effect.predefinedType)),
+ )
+ }
+ }
+ }
+
is ActionData.TalkBackGesture -> {
val actionLabel = getString(TalkBackGestureStrings.getActionLabel(action.gesture))
getString(R.string.action_talkback_gesture_formatted, actionLabel)
@@ -891,76 +896,116 @@ class ActionUiHelper(
}
fun getOptionLabels(keyMap: KeyMap, action: Action) = buildList {
- if (keyMap.isRepeatingActionsAllowed() && action.repeat) {
- val repeatDescription = buildString {
- append(getString(R.string.flag_repeat_build_description_start))
+ getRepeatDescription(keyMap, action)?.let { add(it) }
- val repeatLimit = when {
- action.repeatLimit != null -> action.repeatLimit
+ if (keyMap.isHoldingDownActionAllowed(action) &&
+ action.holdDown &&
+ !action.stopHoldDownWhenTriggerPressedAgain
+ ) {
+ add(getString(R.string.flag_hold_down))
+ }
- action.repeatMode == RepeatMode.LIMIT_REACHED -> 1
+ if (keyMap.isHoldingDownActionAllowed(action) &&
+ action.holdDown &&
+ action.stopHoldDownWhenTriggerPressedAgain
+ ) {
+ add(getString(R.string.flag_hold_down_until_pressed_again))
+ }
+ }
- // and is null
- else -> null
- }
+ /**
+ * @return null if the action does not repeat.
+ */
+ fun getRepeatDescription(keyMap: KeyMap, action: Action): String? {
+ if (!keyMap.isRepeatingActionsAllowed() || !action.repeat) {
+ return null
+ }
- if (repeatLimit != null) {
- append(" ")
- append(getString(R.string.flag_repeat_build_description_limit, repeatLimit))
- }
+ return buildString {
+ append(getString(R.string.flag_repeat_build_description_start))
- if (action.repeatRate != null) {
- append(" ")
- append(
- getString(
- R.string.flag_repeat_build_description_repeat_rate,
- action.repeatRate,
- ),
- )
- }
+ val repeatLimit = when {
+ action.repeatLimit != null -> action.repeatLimit
- if (action.repeatDelay != null) {
- append(" ")
- append(
- getString(
- R.string.flag_repeat_build_description_repeat_delay,
- action.repeatDelay,
- ),
- )
- }
+ action.repeatMode == RepeatMode.LIMIT_REACHED -> 1
+ // and is null
+ else -> null
+ }
+
+ if (repeatLimit != null) {
append(" ")
+ append(getString(R.string.flag_repeat_build_description_limit, repeatLimit))
+ }
- when (action.repeatMode) {
- RepeatMode.TRIGGER_RELEASED -> {
- append(getString(R.string.flag_repeat_build_description_until_released))
- }
+ if (action.repeatRate != null) {
+ append(" ")
+ append(
+ getString(
+ R.string.flag_repeat_build_description_repeat_rate,
+ action.repeatRate,
+ ),
+ )
+ }
- RepeatMode.TRIGGER_PRESSED_AGAIN -> {
- append(
- getString(R.string.flag_repeat_build_description_until_pressed_again),
- )
- }
+ if (action.repeatDelay != null) {
+ append(" ")
+ append(
+ getString(
+ R.string.flag_repeat_build_description_repeat_delay,
+ action.repeatDelay,
+ ),
+ )
+ }
+
+ append(" ")
- else -> Unit
+ when (action.repeatMode) {
+ RepeatMode.TRIGGER_RELEASED -> {
+ append(getString(R.string.flag_repeat_build_description_until_released))
}
+
+ RepeatMode.TRIGGER_PRESSED_AGAIN -> {
+ append(
+ getString(R.string.flag_repeat_build_description_until_pressed_again),
+ )
+ }
+
+ else -> Unit
}
+ }
+ }
+
+ /**
+ * @return null if the action is not performed in a burst.
+ */
+ fun getBurstDescription(action: Action): String? {
+ val multiplier = action.multiplier ?: return null
- add(repeatDescription)
+ if (multiplier <= 1) {
+ return null
}
- if (keyMap.isHoldingDownActionAllowed(action) &&
- action.holdDown &&
- !action.stopHoldDownWhenTriggerPressedAgain
- ) {
- add(getString(R.string.flag_hold_down))
+ return getString(R.string.action_list_burst, multiplier)
+ }
+
+ /**
+ * @return null if the action is not held down.
+ */
+ fun getHoldDownDescription(keyMap: KeyMap, action: Action): String? {
+ if (!keyMap.isHoldingDownActionAllowed(action) || !action.holdDown) {
+ return null
}
- if (keyMap.isHoldingDownActionAllowed(action) &&
- action.holdDown &&
- action.stopHoldDownWhenTriggerPressedAgain
- ) {
- add(getString(R.string.flag_hold_down_until_pressed_again))
+ return when {
+ keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) &&
+ action.holdDownDuration != null ->
+ getString(R.string.action_list_hold_down_duration, action.holdDownDuration)
+
+ action.stopHoldDownWhenTriggerPressedAgain ->
+ getString(R.string.flag_hold_down_until_pressed_again)
+
+ else -> getString(R.string.flag_hold_down)
}
}
}
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 6f464f5e54..bda0aae9dc 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
@@ -68,6 +68,7 @@ import androidx.compose.material.icons.outlined.StopCircle
import androidx.compose.material.icons.outlined.Swipe
import androidx.compose.material.icons.outlined.TouchApp
import androidx.compose.material.icons.outlined.VerticalSplit
+import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.outlined.ViewArray
import androidx.compose.material.icons.outlined.WifiTethering
import androidx.compose.material.icons.outlined.WifiTetheringOff
@@ -231,6 +232,7 @@ object ActionUtils {
ActionId.PERFORM_IME_ACTION -> ActionCategory.KEYBOARD
ActionId.SWITCH_KEYBOARD -> ActionCategory.KEYBOARD
ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionCategory.KEYBOARD
+ ActionId.CYCLE_KEYBOARD -> ActionCategory.KEYBOARD
ActionId.LOCK_DEVICE -> ActionCategory.INTERFACE
ActionId.POWER_ON_OFF_DEVICE -> ActionCategory.INTERFACE
ActionId.SECURE_LOCK_DEVICE -> ActionCategory.INTERFACE
@@ -244,6 +246,7 @@ object ActionUtils {
ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS
ActionId.CREATE_NOTIFICATION -> ActionCategory.NOTIFICATIONS
ActionId.TOAST -> ActionCategory.NOTIFICATIONS
+ ActionId.VIBRATE -> ActionCategory.NOTIFICATIONS
ActionId.DEVICE_CONTROLS -> ActionCategory.APPS
ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS
ActionId.FORCE_STOP_APP -> ActionCategory.APPS
@@ -439,6 +442,8 @@ object ActionUtils {
ActionId.CYCLE_KEYBOARD_LANGUAGE -> R.string.action_cycle_keyboard_language
+ ActionId.CYCLE_KEYBOARD -> R.string.action_cycle_keyboard
+
ActionId.TOGGLE_AIRPLANE_MODE -> R.string.action_toggle_airplane_mode
ActionId.ENABLE_AIRPLANE_MODE -> R.string.action_enable_airplane_mode
@@ -498,6 +503,8 @@ object ActionUtils {
ActionId.TOAST -> R.string.action_toast
+ ActionId.VIBRATE -> R.string.action_vibrate
+
ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call
ActionId.END_PHONE_CALL -> R.string.action_end_call
@@ -912,6 +919,10 @@ object ActionUtils {
ActionId.CYCLE_KEYBOARD_LANGUAGE -> return listOf(Permission.WRITE_SECURE_SETTINGS)
+ ActionId.CYCLE_KEYBOARD -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ return listOf(Permission.WRITE_SECURE_SETTINGS)
+ }
+
ActionId.TOGGLE_AIRPLANE_MODE,
ActionId.ENABLE_AIRPLANE_MODE,
ActionId.DISABLE_AIRPLANE_MODE,
@@ -963,6 +974,12 @@ object ActionUtils {
// Permissions handled based on setting type at runtime
ActionId.MODIFY_SETTING -> return emptyList()
+ ActionId.HTTP_REQUEST -> if (Build.VERSION.SDK_INT >=
+ Build.VERSION_CODES.CINNAMON_BUN
+ ) {
+ return listOf(Permission.ACCESS_LOCAL_NETWORK)
+ }
+
else -> return emptyList()
}
@@ -1062,6 +1079,7 @@ object ActionUtils {
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.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive
ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive
ActionId.DISABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeInactive
@@ -1093,6 +1111,7 @@ object ActionUtils {
ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll
ActionId.CREATE_NOTIFICATION -> Icons.AutoMirrored.Outlined.Message
ActionId.TOAST -> Icons.AutoMirrored.Outlined.Message
+ ActionId.VIBRATE -> Icons.Outlined.Vibration
ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call
ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd
ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice
@@ -1155,6 +1174,7 @@ fun ActionData.isEditable(): Boolean = when (this) {
is ActionData.ShellCommand,
is ActionData.CreateNotification,
is ActionData.Toast,
+ is ActionData.Vibrate,
is ActionData.InteractUiElement,
is ActionData.MoveCursor,
is ActionData.ModifySetting,
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 32ce5cf4a9..499d51fa94 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
@@ -8,18 +8,25 @@ 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.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ArrowDownward
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
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -27,11 +34,14 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -44,14 +54,25 @@ import io.github.sds100.keymapper.base.keymaps.ShortcutModel
import io.github.sds100.keymapper.base.keymaps.ShortcutRow
import io.github.sds100.keymapper.base.onboarding.OnboardingTipModel
import io.github.sds100.keymapper.base.onboarding.TipCard
-import io.github.sds100.keymapper.base.utils.ui.LinkType
+import io.github.sds100.keymapper.base.utils.ui.SliderMaximums
+import io.github.sds100.keymapper.base.utils.ui.SliderMinimums
+import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
+import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog
import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem
+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.system.camera.CameraLens
import kotlinx.coroutines.flow.update
+/**
+ * The height of the row between action cards. The last card has a spacer of the same height so
+ * the height of the items stays constant while dragging.
+ */
+private val linkRowHeight = 48.dp
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewModel) {
@@ -95,15 +116,11 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod
modifier = modifier,
state = state,
tipModel = actionTipModel,
+ callback = viewModel,
onRemoveClick = viewModel::onRemoveClick,
- onEditClick = viewModel::onEditClick,
- onMoveAction = viewModel::onMoveAction,
- onFixErrorClick = viewModel::onFixError,
- onClickShortcut = viewModel::onClickShortcut,
- onTestClick = viewModel::onTestClick,
onAddClick = viewModel::onAddActionClick,
- onActionTipDismiss = viewModel::onActionTipDismissClick,
- onTipButtonClick = viewModel::onTipButtonClick,
+ onDelayChange = viewModel::onDelayChanged,
+ onRenameAction = viewModel::onRenameAction,
)
}
@@ -112,22 +129,21 @@ private fun ActionsScreen(
modifier: Modifier = Modifier,
state: State,
tipModel: OnboardingTipModel? = null,
+ callback: ActionListCallback = object : ActionListCallback {},
onAddClick: () -> Unit = {},
onRemoveClick: (String) -> Unit = {},
- onEditClick: (String) -> Unit = {},
- onMoveAction: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> },
- onFixErrorClick: (String) -> Unit = {},
- onTestClick: (String) -> Unit = {},
- onClickShortcut: (ActionData) -> Unit = {},
- onActionTipDismiss: () -> Unit = {},
- onTipButtonClick: (String) -> Unit = {},
+ onDelayChange: (String, Int) -> Unit = { _, _ -> },
+ onRenameAction: (String, String) -> Unit = { _, _ -> },
) {
- var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
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 (showDeleteDialog && actionToDelete != null) {
+ if (actionToDelete != null) {
AlertDialog(
- onDismissRequest = { showDeleteDialog = false },
+ onDismissRequest = { actionToDelete = null },
title = {
Text(stringResource(R.string.action_list_delete_dialog_title))
},
@@ -136,41 +152,63 @@ private fun ActionsScreen(
TextButton(
onClick = {
onRemoveClick(actionToDelete!!)
- showDeleteDialog = false
+ actionToDelete = null
},
) {
Text(stringResource(R.string.action_list_delete_yes))
}
},
dismissButton = {
- TextButton(onClick = { showDeleteDialog = false }) {
+ TextButton(onClick = { actionToDelete = null }) {
Text(stringResource(R.string.action_list_delete_cancel))
}
},
)
}
+ val renameModel = actions.find { it.id == actionToRename }
+
+ if (renameModel != null) {
+ TextFieldDialog(
+ title = stringResource(R.string.action_options_custom_name_dialog_title),
+ submitButtonText = stringResource(R.string.pos_save),
+ initialText = renameModel.title,
+ hint = renameModel.title,
+ canBeEmpty = true,
+ onSubmitClick = { newText ->
+ onRenameAction(renameModel.id, newText)
+ null
+ },
+ onDismissRequest = { actionToRename = null },
+ )
+ }
+
+ val delayModel = actions.find { it.id == actionToSetDelay }
+
+ if (delayModel != null) {
+ DelayBeforeNextActionDialog(
+ initialDelay = delayModel.delayBeforeNextAction,
+ onSaveClick = { delay ->
+ onDelayChange(delayModel.id, delay)
+ actionToSetDelay = null
+ },
+ onDismissRequest = { actionToSetDelay = null },
+ )
+ }
+
when (state) {
State.Loading -> Loading()
is State.Data -> Surface(modifier = modifier) {
Column {
- Spacer(Modifier.height(8.dp))
ActionList(
modifier = Modifier.weight(1f),
state = state.data,
tipModel = tipModel,
- onRemoveClick = {
- actionToDelete = it
- showDeleteDialog = true
- },
- onEditClick = onEditClick,
- onFixErrorClick = onFixErrorClick,
- onMove = onMoveAction,
- onClickShortcut = onClickShortcut,
- onTestClick = onTestClick,
- onActionTipDismiss,
- onTipButtonClick,
+ callback = callback,
+ onRemoveClick = { actionToDelete = it },
+ onDelayClick = { actionToSetDelay = it },
+ onRenameClick = { actionToRename = it },
)
FilledTonalButton(
@@ -197,36 +235,40 @@ private fun Loading(modifier: Modifier = Modifier) {
}
}
+interface ActionListCallback {
+ fun onEditClick(id: String) = run { }
+ fun onFixErrorClick(id: String) = run { }
+ fun onMove(fromIndex: Int, toIndex: Int) = run { }
+ fun onClickShortcut(data: ActionData) = run { }
+ fun onTestClick(id: String) = run { }
+ fun onDuplicateClick(id: String) = run { }
+ fun onActionTipDismiss() = run { }
+ fun onTipButtonClick(id: String) = run { }
+ fun onEnabledChange(id: String, enabled: Boolean) = run { }
+ fun onExpandedChange(id: String, expanded: Boolean) = run {}
+}
+
@Composable
private fun ActionList(
modifier: Modifier = Modifier,
state: ConfigActionsState,
tipModel: OnboardingTipModel?,
+ callback: ActionListCallback,
onRemoveClick: (String) -> Unit,
- onEditClick: (String) -> Unit,
- onFixErrorClick: (String) -> Unit,
- onMove: (fromIndex: Int, toIndex: Int) -> Unit,
- onClickShortcut: (ActionData) -> Unit,
- onTestClick: (String) -> Unit,
- onActionTipDismiss: () -> Unit,
- onTipButtonClick: (String) -> Unit,
+ onDelayClick: (String) -> Unit,
+ onRenameClick: (String) -> Unit,
) {
val lazyListState = rememberLazyListState()
- val dragDropState = if (state is ConfigActionsState.Loaded) {
- rememberDragDropState(
- lazyListState = lazyListState,
- onMove = onMove,
- // Do not drag and drop the row of shortcuts
- ignoreLastItems = if (state.shortcuts.isEmpty()) {
- 0
- } else {
- 1
- },
- )
- } else {
- null
- }
+ val actions = (state as? ConfigActionsState.Loaded)?.actions.orEmpty()
+ val actionIds = remember(actions) { actions.map { it.id } }
+
+ // Only the actions can be dragged. Not the tip or the row of shortcuts.
+ val dragDropState = rememberDragDropState(
+ lazyListState = lazyListState,
+ keys = actionIds,
+ onMove = callback::onMove,
+ )
// Use dragContainer rather than .draggable() modifier because that causes
// dragging the first item to be always be dropped in the next position.
@@ -249,12 +291,12 @@ private fun ActionList(
title = tip.title,
message = tip.message,
isDismissable = tip.isDismissable,
- onDismiss = onActionTipDismiss,
+ onDismiss = callback::onActionTipDismiss,
buttonText = tip.buttonText,
- onButtonClick = { onTipButtonClick(tip.id) },
+ onButtonClick = { callback.onTipButtonClick(tip.id) },
)
- Spacer(Modifier.height(8.dp))
+ Spacer(Modifier.height(16.dp))
}
}
@@ -272,40 +314,67 @@ private fun ActionList(
}
is ConfigActionsState.Loaded -> {
+ val orderedActions = dragDropState.ordered(state.actions) { it.id }
+
itemsIndexed(
- state.actions,
+ orderedActions,
key = { _, item -> item.id },
contentType = { _, _ -> "action" },
) { index, model ->
+
DraggableItem(
- dragDropState = dragDropState!!,
- index = index,
+ dragDropState = dragDropState,
+ key = model.id,
) { isDragging ->
- ActionListItem(
- modifier = Modifier.fillMaxWidth(),
- model = model,
- index = index,
- isDraggingEnabled = state.actions.size > 1,
- isDragging = isDragging,
- isReorderingEnabled = state.isReorderingEnabled,
- dragDropState = dragDropState,
- onEditClick = { onEditClick(model.id) },
- onRemoveClick = { onRemoveClick(model.id) },
- onFixClick = { onFixErrorClick(model.id) },
- onTestClick = { onTestClick(model.id) },
- onMoveUp = if (state.isReorderingEnabled && index > 0) {
- { onMove(index, index - 1) }
- } else {
- null
- },
- onMoveDown = if (state.isReorderingEnabled &&
- index < state.actions.size - 1
- ) {
- { onMove(index, index + 1) }
- } else {
- null
- },
- )
+ Column {
+ ActionListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ model = model,
+ index = index,
+ isExpanded = model.isExpanded,
+ isDraggingEnabled = orderedActions.size > 1,
+ isDragging = isDragging,
+ isReorderingEnabled = state.isReorderingEnabled,
+ dragDropState = dragDropState,
+ onExpandedChange = { callback.onExpandedChange(model.id, it) },
+ onEditClick = { callback.onEditClick(model.id) },
+ onRemoveClick = { onRemoveClick(model.id) },
+ onFixClick = { callback.onFixErrorClick(model.id) },
+ onTestClick = { callback.onTestClick(model.id) },
+ onDuplicateClick = { callback.onDuplicateClick(model.id) },
+ onRenameClick = { onRenameClick(model.id) },
+ onEnabledChange = { callback.onEnabledChange(model.id, it) },
+ onMoveUp = if (state.isReorderingEnabled && index > 0) {
+ { callback.onMove(index, index - 1) }
+ } else {
+ null
+ },
+ onMoveDown = if (state.isReorderingEnabled &&
+ index < orderedActions.size - 1
+ ) {
+ { callback.onMove(index, index + 1) }
+ } else {
+ null
+ },
+ )
+
+ if (model.showDelayChip) {
+ ActionLinkRow(
+ isEnabled = model.isEnabled,
+ delayBeforeNextAction = model.delayBeforeNextAction,
+ onDelayClick = { onDelayClick(model.id) },
+ )
+ } else if (index != orderedActions.lastIndex) {
+ // Important! Keep the height of the item constant while dragging.
+ // If the height changes while dragging it can lead to janky
+ // behavior.
+ //
+ // But do not show a gap at the bottom of the list.
+ Spacer(Modifier.height(linkRowHeight))
+ }
+ }
}
}
}
@@ -314,6 +383,8 @@ private fun ActionList(
if (state.shortcuts.isNotEmpty()) {
item(key = "shortcuts", contentType = "shortcuts") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(Modifier.height(16.dp))
+
Text(
text = stringResource(R.string.recently_used_actions),
style = MaterialTheme.typography.titleSmall,
@@ -326,7 +397,7 @@ private fun ActionList(
.fillMaxWidth()
.padding(horizontal = 32.dp),
shortcuts = state.shortcuts,
- onClick = { onClickShortcut(it) },
+ onClick = { callback.onClickShortcut(it) },
)
}
}
@@ -334,6 +405,132 @@ private fun ActionList(
}
}
+@Composable
+private fun ActionLinkRow(
+ modifier: Modifier = Modifier,
+ isEnabled: Boolean,
+ delayBeforeNextAction: Int?,
+ onDelayClick: () -> Unit,
+) {
+ val colors = if (isEnabled) {
+ AssistChipDefaults.assistChipColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ leadingIconContentColor = MaterialTheme.colorScheme.onSurface,
+ )
+ } else {
+ AssistChipDefaults.assistChipColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ leadingIconContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
+ labelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
+ )
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(linkRowHeight)
+ .padding(horizontal = 16.dp),
+ ) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .align(Alignment.Center),
+ imageVector = Icons.Rounded.ArrowDownward,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+
+ AssistChip(
+ modifier = Modifier.align(Alignment.CenterEnd),
+ onClick = onDelayClick,
+ label = {
+ if (delayBeforeNextAction == null) {
+ Text(stringResource(R.string.action_list_add_delay))
+ } else {
+ 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)
+ }
+
+ Text(
+ stringResource(
+ R.string.action_title_wait_secs,
+ secondsText,
+ ),
+ )
+ }
+ }
+ },
+ leadingIcon = {
+ Icon(
+ modifier = Modifier.size(AssistChipDefaults.IconSize),
+ imageVector = if (delayBeforeNextAction == null) {
+ Icons.Rounded.MoreTime
+ } else {
+ Icons.Rounded.Timelapse
+ },
+ contentDescription = null,
+ )
+ },
+ colors = colors,
+ border = null,
+ )
+ }
+}
+
+@Composable
+private fun DelayBeforeNextActionDialog(
+ initialDelay: Int?,
+ onSaveClick: (Int) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ var delay by rememberSaveable {
+ mutableIntStateOf(initialDelay ?: SliderStepSizes.DELAY_BEFORE_NEXT_ACTION)
+ }
+
+ CustomDialog(
+ title = stringResource(R.string.action_options_delay_header),
+ confirmButton = {
+ TextButton(onClick = { onSaveClick(delay) }) {
+ Text(stringResource(R.string.pos_save))
+ }
+ },
+ dismissButton = {
+ if (initialDelay == null) {
+ TextButton(onClick = onDismissRequest) {
+ Text(stringResource(R.string.neg_cancel))
+ }
+ } else {
+ TextButton(onClick = { onSaveClick(0) }) {
+ Text(stringResource(R.string.action_list_delay_remove))
+ }
+ }
+ },
+ onDismissRequest = onDismissRequest,
+ ) {
+ val delayMin = SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat()
+ val delayMax = SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat()
+
+ SliderOptionText(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ title = null,
+ defaultValue = delayMin,
+ value = delay.toFloat(),
+ valueText = { "${it.toInt()} ms" },
+ onValueChange = { delay = it.toInt() },
+ valueRange = delayMin..delayMax,
+ stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION,
+ )
+ }
+}
+
@Preview
@Composable
private fun EmptyPreview() {
@@ -392,19 +589,25 @@ private fun LoadedPreview() {
ActionListItemModel(
id = "1",
icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn),
- text = "Toggle Back flashlight",
- secondaryText = "Repeat until released",
+ title = "Toggle Back flashlight",
+ summary = "Repeat 5x until pressed again • Hold down",
error = "Flashlight not found",
isErrorFixable = true,
- linkType = LinkType.ARROW,
+ showDelayChip = true,
),
ActionListItemModel(
id = "2",
+ icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch),
+ title = "Open magnifier",
+ isCustomName = true,
+ showDelayChip = true,
+ delayBeforeNextAction = 100,
+ isEnabled = false,
+ ),
+ ActionListItemModel(
+ id = "3",
icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn),
- text = "Toggle Back flashlight",
- secondaryText = "Repeat until released",
- error = null,
- isErrorFixable = true,
+ title = "Toggle Back flashlight",
),
),
shortcuts = setOf(
@@ -428,3 +631,15 @@ private fun LoadedPreview() {
)
}
}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun DelayDialogPreview() {
+ KeyMapperTheme {
+ DelayBeforeNextActionDialog(
+ initialDelay = 400,
+ onSaveClick = {},
+ onDismissRequest = {},
+ )
+ }
+}
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 0915c11e8b..0e5a33371a 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
@@ -60,6 +60,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) {
ModifySettingActionBottomSheet(delegate)
CreateNotificationActionBottomSheet(delegate)
ToastActionBottomSheet(delegate)
+ VibrateActionBottomSheet(delegate)
StepMediaActionBottomSheet(delegate)
PickTalkBackGestureDialog(delegate)
}
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 df76275e5f..1421b9e235 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.vibration.VibrateConfigDelegate
import io.github.sds100.keymapper.common.utils.State
import io.github.sds100.keymapper.system.SystemError
import io.github.sds100.keymapper.system.permissions.Permission
@@ -33,6 +34,7 @@ import kotlinx.serialization.json.Json
@HiltViewModel
class ChooseActionViewModel @Inject constructor(
private val useCase: CreateActionUseCase,
+ private val vibrateConfigDelegate: VibrateConfigDelegate,
resourceProvider: ResourceProvider,
navigationProvider: NavigationProvider,
dialogProvider: DialogProvider,
@@ -60,7 +62,7 @@ class ChooseActionViewModel @Inject constructor(
}
val createActionDelegate =
- CreateActionDelegate(viewModelScope, useCase, this, this, this)
+ CreateActionDelegate(viewModelScope, useCase, this, this, this, vibrateConfigDelegate)
private val allGroupedListItems: List by lazy { buildListGroups() }
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt
index 96aee5ec15..2fb3bd8548 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt
@@ -8,11 +8,13 @@ import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase
import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey
import io.github.sds100.keymapper.common.utils.State
+import io.github.sds100.keymapper.common.utils.dataOrNull
import io.github.sds100.keymapper.common.utils.moveElement
import io.github.sds100.keymapper.data.Keys
import io.github.sds100.keymapper.data.repositories.PreferenceRepository
import io.github.sds100.keymapper.system.inputevents.KeyEventUtils
import java.util.LinkedList
+import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -40,31 +42,33 @@ class ConfigActionsUseCaseImpl @Inject constructor(
.map(::getActionShortcuts)
.map { it.take(5) }
- override fun addAction(data: ActionData) {
- state.update { keyMap ->
- val newActionList = keyMap.actionList.toMutableList().apply {
- add(createAction(keyMap, data))
- }
+ override fun addAction(data: ActionData): Action? {
+ val newKeyMap = state.update { keyMap ->
+ val action = createAction(keyMap, data)
- preferenceRepository.update(
- Keys.recentlyUsedActions,
- { old ->
- val oldList: List = if (old == null) {
- emptyList()
- } else {
- Json.decodeFromString>(old)
- }
-
- val newShortcuts = LinkedList(oldList)
- .also { it.addFirst(data) }
- .distinct()
-
- Json.encodeToString(newShortcuts)
- },
- )
+ val newActionList = keyMap.actionList.plus(action)
keyMap.copy(actionList = newActionList)
}
+
+ preferenceRepository.update(
+ Keys.recentlyUsedActions,
+ { old ->
+ val oldList: List = if (old == null) {
+ emptyList()
+ } else {
+ Json.decodeFromString>(old)
+ }
+
+ val newShortcuts = LinkedList(oldList)
+ .also { it.addFirst(data) }
+ .distinct()
+
+ Json.encodeToString(newShortcuts)
+ },
+ )
+
+ return newKeyMap.dataOrNull()?.actionList?.last()
}
override fun moveAction(fromIndex: Int, toIndex: Int) {
@@ -83,6 +87,20 @@ class ConfigActionsUseCaseImpl @Inject constructor(
}
}
+ override fun duplicateAction(uid: String) {
+ updateActionList { actionList ->
+ val index = actionList.indexOfFirst { it.uid == uid }
+
+ if (index == -1) {
+ return@updateActionList actionList
+ }
+
+ val duplicate = actionList[index].copy(uid = UUID.randomUUID().toString())
+
+ actionList.toMutableList().apply { add(index + 1, duplicate) }
+ }
+ }
+
override fun setActionData(uid: String, data: ActionData) {
updateActionList { actionList ->
actionList.map { action ->
@@ -183,6 +201,16 @@ class ConfigActionsUseCaseImpl @Inject constructor(
}
}
+ override fun setActionCustomName(uid: String, customName: String?) {
+ setActionOption(uid) { action ->
+ action.copy(customName = customName?.trim()?.takeIf { it.isNotEmpty() })
+ }
+ }
+
+ override fun setActionEnabled(uid: String, enabled: Boolean) {
+ setActionOption(uid) { action -> action.copy(isEnabled = enabled) }
+ }
+
private suspend fun getActionShortcuts(json: String?): List {
if (json == null) {
return emptyList()
@@ -222,12 +250,18 @@ class ConfigActionsUseCaseImpl @Inject constructor(
repeat = true
}
+ // Actions repeat until the trigger is released by default so do not repeat if
+ // the release of the trigger can not be detected.
+ if (!keyMap.isRepeatUntilReleasedAllowed()) {
+ repeat = false
+ }
+
if (data is ActionData.AnswerCall) {
- configConstraints.addConstraint(ConstraintData.PhoneRinging)
+ configConstraints.addConstraint(groupUid = null, ConstraintData.PhoneRinging)
}
if (data is ActionData.EndCall) {
- configConstraints.addConstraint(ConstraintData.InPhoneCall)
+ configConstraints.addConstraint(groupUid = null, ConstraintData.InPhoneCall)
}
return Action(
@@ -261,14 +295,20 @@ class ConfigActionsUseCaseImpl @Inject constructor(
interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase {
val keyMap: StateFlow>
- fun addAction(data: ActionData)
+ /**
+ * @return the new action, or null if no key map was set.
+ */
+ fun addAction(data: ActionData): Action?
fun moveAction(fromIndex: Int, toIndex: Int)
fun removeAction(uid: String)
+ fun duplicateAction(uid: String)
val recentlyUsedActions: Flow>
fun setActionData(uid: String, data: ActionData)
fun setActionMultiplier(uid: String, multiplier: Int)
fun setDelayBeforeNextAction(uid: String, delay: Int)
+ fun setActionCustomName(uid: String, customName: String?)
+ fun setActionEnabled(uid: String, enabled: Boolean)
fun setActionRepeatRate(uid: String, repeatRate: Int)
fun setActionRepeatLimit(uid: String, repeatLimit: Int)
fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String)
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 b606478b92..b384eb3da4 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
@@ -18,10 +18,10 @@ 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.DialogProvider
-import io.github.sds100.keymapper.base.utils.ui.LinkType
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.vibration.VibrateConfigDelegate
import io.github.sds100.keymapper.common.utils.AccessibilityServiceError
import io.github.sds100.keymapper.common.utils.KMError
import io.github.sds100.keymapper.common.utils.State
@@ -53,12 +53,14 @@ class ConfigActionsViewModel @Inject constructor(
private val onboardingUseCase: OnboardingUseCase,
setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate,
fixKeyEventActionDelegate: FixKeyEventActionDelegate,
- onboardingTipDelegate: OnboardingTipDelegate,
+ private val onboardingTipDelegate: OnboardingTipDelegate,
+ private val vibrateConfigDelegate: VibrateConfigDelegate,
resourceProvider: ResourceProvider,
navigationProvider: NavigationProvider,
dialogProvider: DialogProvider,
) : ViewModel(),
ActionOptionsBottomSheetCallback,
+ ActionListCallback,
SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate,
ResourceProvider by resourceProvider,
DialogProvider by dialogProvider,
@@ -67,7 +69,7 @@ class ConfigActionsViewModel @Inject constructor(
OnboardingTipDelegate by onboardingTipDelegate {
val createActionDelegate =
- CreateActionDelegate(viewModelScope, createAction, this, this, this)
+ CreateActionDelegate(viewModelScope, createAction, this, this, this, vibrateConfigDelegate)
private val uiHelper = ActionUiHelper(displayAction, resourceProvider)
private val _state = MutableStateFlow>(State.Loading)
@@ -78,6 +80,9 @@ class ConfigActionsViewModel @Inject constructor(
actions.map(::buildShortcut).toSet()
}.stateIn(viewModelScope, SharingStarted.Lazily, emptySet())
+ private val expandedActions: MutableStateFlow> = MutableStateFlow(emptySet())
+ private val knownActions: MutableSet = mutableSetOf()
+
val actionOptionsUid = MutableStateFlow(null)
val actionOptionsState: StateFlow =
combine(config.keyMap, actionOptionsUid, transform = ::buildOptionsState)
@@ -89,14 +94,40 @@ class ConfigActionsViewModel @Inject constructor(
displayAction.actionErrorSnapshot.stateIn(viewModelScope, SharingStarted.Lazily, null)
init {
+ viewModelScope.launch {
+ config.keyMap
+ .map { state -> state.dataOrNull()?.actionList }
+ .filterNotNull()
+ .collect { actionList ->
+ val errorSnapshot = displayAction.actionErrorSnapshot.first()
+ val newExpandedActions = mutableSetOf()
+
+ for (action in actionList) {
+ // Only expand an action automatically if it is the first time it has
+ // been seen.
+ if (!knownActions.contains(action.uid) &&
+ errorSnapshot.getError(action.data) != null
+ ) {
+ newExpandedActions.add(action.uid)
+ }
+ }
+
+ // Expand all the actions at once and not one-by-one
+ expandedActions.update { set -> set.plus(newExpandedActions) }
+
+ knownActions.addAll(actionList.map { it.uid })
+ }
+ }
+
combine(
config.keyMap,
shortcuts,
displayAction.showDeviceDescriptors,
actionErrorSnapshot.filterNotNull(),
- ) { keyMapState, shortcuts, showDeviceDescriptors, errorSnapshot ->
+ expandedActions,
+ ) { keyMapState, shortcuts, showDeviceDescriptors, errorSnapshot, expandedActions ->
_state.value = keyMapState.mapData { keyMap ->
- buildState(keyMap, shortcuts, errorSnapshot, showDeviceDescriptors)
+ buildState(keyMap, shortcuts, errorSnapshot, showDeviceDescriptors, expandedActions)
}
}.launchIn(viewModelScope)
@@ -113,13 +144,17 @@ class ConfigActionsViewModel @Inject constructor(
return config.keyMap.first().dataOrNull()?.actionList?.singleOrNull { it.uid == uid }?.data
}
- fun onClickShortcut(action: ActionData) {
+ override fun onClickShortcut(data: ActionData) {
viewModelScope.launch {
- config.addAction(action)
+ val action = config.addAction(data)
+
+ if (action != null) {
+ expandedActions.update { set -> set.plus(action.uid) }
+ }
}
}
- fun onFixError(actionUid: String) {
+ override fun onFixErrorClick(actionUid: String) {
viewModelScope.launch {
val actionData = getActionData(actionUid) ?: return@launch
val error =
@@ -160,14 +195,18 @@ class ConfigActionsViewModel @Inject constructor(
viewModelScope.launch {
val actionData = navigate("add_action", NavDestination.ChooseAction) ?: return@launch
- config.addAction(actionData)
+ val action = config.addAction(actionData)
+
+ if (action != null) {
+ expandedActions.update { set -> set.plus(action.uid) }
+ }
// Never show the tap target to add an action again.
onboardingUseCase.completedTapTarget(OnboardingTapTarget.CHOOSE_ACTION)
}
}
- fun onMoveAction(fromIndex: Int, toIndex: Int) {
+ override fun onMove(fromIndex: Int, toIndex: Int) {
config.moveAction(fromIndex, toIndex)
}
@@ -175,17 +214,21 @@ class ConfigActionsViewModel @Inject constructor(
config.removeAction(actionUid)
}
- fun onEditClick(actionUid: String) {
+ override fun onEditClick(actionUid: String) {
actionOptionsUid.value = actionUid
}
- fun onTestClick(actionUid: String) {
+ override fun onTestClick(actionUid: String) {
viewModelScope.launch {
val actionData = getActionData(actionUid) ?: return@launch
attemptTestAction(actionData)
}
}
+ override fun onDuplicateClick(actionUid: String) {
+ config.duplicateAction(actionUid)
+ }
+
override fun onEditClick() {
val actionUid = actionOptionsUid.value ?: return
viewModelScope.launch {
@@ -213,10 +256,6 @@ class ConfigActionsViewModel @Inject constructor(
}
}
- override fun onRepeatCheckedChange(checked: Boolean) {
- actionOptionsUid.value?.let { uid -> config.setActionRepeatEnabled(uid, checked) }
- }
-
override fun onRepeatLimitChanged(limit: Int) {
actionOptionsUid.value?.let { uid -> config.setActionRepeatLimit(uid, limit) }
}
@@ -229,43 +268,87 @@ class ConfigActionsViewModel @Inject constructor(
actionOptionsUid.value?.let { uid -> config.setActionRepeatDelay(uid, delay) }
}
- override fun onHoldDownCheckedChange(checked: Boolean) {
- actionOptionsUid.value?.let { uid -> config.setActionHoldDownEnabled(uid, checked) }
- }
-
override fun onHoldDownDurationChanged(duration: Int) {
actionOptionsUid.value?.let { uid -> config.setActionHoldDownDuration(uid, duration) }
}
- override fun onSelectHoldDownMode(holdDownMode: HoldDownMode) {
- actionOptionsUid.value?.let { uid ->
- config.setActionStopHoldingDownWhenTriggerPressedAgain(
- uid,
- holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN,
- )
+ override fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) {
+ val uid = actionOptionsUid.value ?: return
+
+ if (holdDownMode == null) {
+ config.setActionHoldDownEnabled(uid, false)
+ return
}
+
+ config.setActionHoldDownEnabled(uid, true)
+ config.setActionStopHoldingDownWhenTriggerPressedAgain(
+ uid,
+ holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN,
+ )
+ }
+
+ fun onDelayChanged(actionUid: String, delay: Int) {
+ config.setDelayBeforeNextAction(actionUid, delay)
}
- override fun onDelayBeforeNextActionChanged(delay: Int) {
- actionOptionsUid.value?.let { uid -> config.setDelayBeforeNextAction(uid, delay) }
+ override fun onEnabledChange(actionUid: String, enabled: Boolean) {
+ config.setActionEnabled(actionUid, enabled)
+ }
+
+ override fun onActionTipDismiss() {
+ onActionTipDismissClick()
+ }
+
+ override fun onTipButtonClick(id: String) {
+ onboardingTipDelegate.onTipButtonClick(id)
}
override fun onMultiplierChanged(multiplier: Int) {
actionOptionsUid.value?.let { uid -> config.setActionMultiplier(uid, multiplier) }
}
- override fun onSelectRepeatMode(repeatMode: RepeatMode) {
- actionOptionsUid.value?.let { uid ->
- when (repeatMode) {
- RepeatMode.TRIGGER_RELEASED -> config.setActionStopRepeatingWhenTriggerReleased(
- uid,
- )
+ override fun onSelectRepeatMode(repeatMode: RepeatMode?) {
+ val uid = actionOptionsUid.value ?: return
+
+ if (repeatMode == null) {
+ config.setActionRepeatEnabled(uid, false)
+ return
+ }
- RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid)
+ config.setActionRepeatEnabled(uid, true)
- RepeatMode.TRIGGER_PRESSED_AGAIN ->
- config.setActionStopRepeatingWhenTriggerPressedAgain(uid)
- }
+ when (repeatMode) {
+ RepeatMode.TRIGGER_RELEASED -> config.setActionStopRepeatingWhenTriggerReleased(uid)
+
+ RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid)
+
+ RepeatMode.TRIGGER_PRESSED_AGAIN ->
+ config.setActionStopRepeatingWhenTriggerPressedAgain(uid)
+ }
+ }
+
+ override fun onCustomNameChanged(name: String) {
+ val uid = actionOptionsUid.value ?: return
+ onRenameAction(uid, name)
+ }
+
+ override fun onExpandedChange(id: String, expanded: Boolean) {
+ if (expanded) {
+ expandedActions.update { set -> set.plus(id) }
+ } else {
+ expandedActions.update { set -> set.minus(id) }
+ }
+ }
+
+ fun onRenameAction(uid: String, name: String) {
+ viewModelScope.launch {
+ val actionData = getActionData(uid) ?: return@launch
+ val showDeviceDescriptors = displayAction.showDeviceDescriptors.first()
+ val generatedTitle = uiHelper.getTitle(actionData, showDeviceDescriptors)
+
+ // Submitting the generated title removes the custom name.
+ val customName = name.trim().takeIf { it.isNotEmpty() && it != generatedTitle }
+ config.setActionCustomName(uid, customName)
}
}
@@ -290,13 +373,14 @@ class ConfigActionsViewModel @Inject constructor(
shortcuts: Set>,
errorSnapshot: ActionErrorSnapshot,
showDeviceDescriptors: Boolean,
+ expandedActions: Set,
): ConfigActionsState {
if (keyMap.actionList.isEmpty()) {
return ConfigActionsState.Empty(shortcuts = shortcuts)
}
val actions =
- createListItems(keyMap, showDeviceDescriptors, errorSnapshot)
+ createListItems(keyMap, showDeviceDescriptors, errorSnapshot, expandedActions)
return ConfigActionsState.Loaded(
actions = actions,
@@ -309,63 +393,41 @@ class ConfigActionsViewModel @Inject constructor(
keyMap: KeyMap,
showDeviceDescriptors: Boolean,
errorSnapshot: ActionErrorSnapshot,
+ expandedActions: Set,
): List {
val actionErrors = errorSnapshot.getErrors(keyMap.actionList.map { it.data })
- return keyMap.actionList.mapIndexed { index, action ->
-
- val title: String = if (action.multiplier != null && action.multiplier > 1) {
- val multiplier = action.multiplier
- "${multiplier}x ${uiHelper.getTitle(action.data, showDeviceDescriptors)}"
- } else {
- uiHelper.getTitle(action.data, showDeviceDescriptors)
- }
+ val midDot = getString(R.string.middot)
+ return keyMap.actionList.mapIndexed { index, action ->
val icon: ComposeIconInfo = uiHelper.getIcon(action.data)
val error: KMError? = actionErrors[action.data]
- val extraInfo = buildString {
- val midDot = getString(R.string.middot)
+ val repeatText = uiHelper.getRepeatDescription(keyMap, action)
+ val burstText = uiHelper.getBurstDescription(action)
+ val holdDownText = uiHelper.getHoldDownDescription(keyMap, action)
- uiHelper.getOptionLabels(keyMap, action).forEachIndexed { index, label ->
- if (index != 0) {
- append(" $midDot ")
- }
-
- append(label)
- }
-
- action.delayBeforeNextAction.apply {
- if (keyMap.isDelayBeforeNextActionAllowed() &&
- action.delayBeforeNextAction != null
- ) {
- if (this@buildString.isNotBlank()) {
- append(" $midDot ")
- }
-
- append(
- getString(
- R.string.action_title_wait,
- action.delayBeforeNextAction,
- ),
- )
- }
- }
- }.takeIf { it.isNotBlank() }
-
- val linkType = when {
- index < keyMap.actionList.lastIndex -> LinkType.ARROW
- else -> LinkType.HIDDEN
- }
+ val summary = listOfNotNull(repeatText, burstText, holdDownText)
+ .joinToString(" $midDot ")
+ .takeIf { it.isNotBlank() }
ActionListItemModel(
id = action.uid,
icon = icon,
- text = title,
- secondaryText = extraInfo,
+ title = uiHelper.getTitle(action, showDeviceDescriptors),
+ isCustomName = !action.customName.isNullOrBlank(),
+ isEnabled = action.isEnabled,
+ isExpanded = expandedActions.contains(action.uid),
+ summary = summary,
error = error?.getFullMessage(this),
isErrorFixable = error?.isFixable ?: true,
- linkType = linkType,
+ showRepeat = keyMap.isRepeatingActionsAllowed(),
+ repeatText = repeatText,
+ burstText = burstText,
+ showHoldDown = keyMap.isHoldingDownActionAllowed(action),
+ holdDownText = holdDownText,
+ showDelayChip = index < keyMap.actionList.lastIndex,
+ delayBeforeNextAction = action.delayBeforeNextAction,
)
}
}
@@ -382,12 +444,17 @@ class ConfigActionsViewModel @Inject constructor(
val allowedRepeatModes = mutableSetOf()
- if (keyMap.isChangingRepeatModeAllowed(action)) {
- allowedRepeatModes.add(RepeatMode.TRIGGER_RELEASED)
+ if (keyMap.isRepeatingActionsAllowed()) {
+ if (keyMap.isRepeatUntilReleasedAllowed()) {
+ allowedRepeatModes.add(RepeatMode.TRIGGER_RELEASED)
+ }
+
allowedRepeatModes.add(RepeatMode.TRIGGER_PRESSED_AGAIN)
allowedRepeatModes.add(RepeatMode.LIMIT_REACHED)
}
+ val showDeviceDescriptors = displayAction.showDeviceDescriptors.first()
+
val defaultRepeatRate = config.defaultRepeatRate.first()
val defaultRepeatDelay = config.defaultRepeatDelay.first()
val defaultHoldDownDuration = config.defaultHoldDownDuration.first()
@@ -404,6 +471,10 @@ class ConfigActionsViewModel @Inject constructor(
keyMap.trigger.keys.any { it is EvdevTriggerKey }
return ActionOptionsState(
+ title = uiHelper.getTitle(action, showDeviceDescriptors),
+ actionTypeTitle = getString(ActionUtils.getTitle(action.data.id)),
+ actionTypeIcon = ActionUtils.getComposeIcon(action.data.id),
+
showEditButton = action.data.isEditable(),
showRepeat = keyMap.isRepeatingActionsAllowed(),
@@ -432,19 +503,12 @@ class ConfigActionsViewModel @Inject constructor(
holdDownDuration = action.holdDownDuration ?: defaultHoldDownDuration,
defaultHoldDownDuration = defaultHoldDownDuration,
- showHoldDownMode = keyMap.isStopHoldingDownActionWhenTriggerPressedAgainAllowed(
- action,
- ),
holdDownMode = if (action.stopHoldDownWhenTriggerPressedAgain) {
HoldDownMode.TRIGGER_PRESSED_AGAIN
} else {
HoldDownMode.TRIGGER_RELEASED
},
- showDelayBeforeNextAction = keyMap.isDelayBeforeNextActionAllowed(),
- delayBeforeNextAction = action.delayBeforeNextAction ?: 0,
- defaultDelayBeforeNextAction = 0,
-
multiplier = action.multiplier ?: 1,
defaultMultiplier = 1,
)
@@ -463,53 +527,3 @@ sealed class ConfigActionsState {
override val shortcuts: Set> = emptySet(),
) : ConfigActionsState()
}
-
-data class ActionListItemModel(
- val id: String,
- val icon: ComposeIconInfo,
- val text: String,
- val secondaryText: String?,
- val error: String? = null,
- val isErrorFixable: Boolean = true,
- val linkType: LinkType = LinkType.HIDDEN,
-)
-
-data class ActionOptionsState(
- val showEditButton: Boolean,
-
- val showRepeat: Boolean,
- val isRepeatChecked: Boolean,
-
- val showRepeatRate: Boolean,
- val showRepeatRateWarning: Boolean,
- val repeatRate: Int,
- val defaultRepeatRate: Int,
-
- val showRepeatDelay: Boolean,
- val repeatDelay: Int,
- val defaultRepeatDelay: Int,
-
- val showRepeatLimit: Boolean,
- val repeatLimit: Int,
- val defaultRepeatLimit: Int,
-
- val allowedRepeatModes: Set,
- val repeatMode: RepeatMode,
-
- val showHoldDown: Boolean,
- val isHoldDownChecked: Boolean,
-
- val showHoldDownDuration: Boolean,
- val holdDownDuration: Int,
- val defaultHoldDownDuration: Int,
-
- val showHoldDownMode: Boolean,
- val holdDownMode: HoldDownMode,
-
- val showDelayBeforeNextAction: Boolean,
- val delayBeforeNextAction: Int,
- val defaultDelayBeforeNextAction: Int,
-
- val multiplier: Int,
- val defaultMultiplier: Int,
-)
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 66671fef6f..f58bd83d0b 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.vibration.VibrateConfigDelegate
import io.github.sds100.keymapper.common.utils.Orientation
import io.github.sds100.keymapper.common.utils.State
import io.github.sds100.keymapper.system.SystemError
@@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
+private const val DEFAULT_VIBRATE_ACTION_DURATION_MS = 200
+
@OptIn(ExperimentalCoroutinesApi::class)
class CreateActionDelegate(
private val coroutineScope: CoroutineScope,
@@ -50,9 +53,11 @@ class CreateActionDelegate(
dialogProvider: DialogProvider,
navigationProvider: NavigationProvider,
resourceProvider: ResourceProvider,
+ vibrateConfigDelegate: VibrateConfigDelegate,
) : ResourceProvider by resourceProvider,
DialogProvider by dialogProvider,
- NavigationProvider by navigationProvider {
+ NavigationProvider by navigationProvider,
+ VibrateConfigDelegate by vibrateConfigDelegate {
val actionResult: MutableStateFlow = MutableStateFlow(null)
var enableFlashlightActionState: EnableFlashlightActionState? by mutableStateOf(null)
@@ -389,6 +394,12 @@ class CreateActionDelegate(
actionResult.update { action }
}
+ fun onDoneVibrateClick() {
+ val effect = buildVibrateEffect() ?: return
+ closeVibrateConfig()
+ actionResult.update { ActionData.Vibrate(effect) }
+ }
+
fun onStepMediaDurationEnabledChange(enabled: Boolean) {
stepMediaActionBottomSheetState =
stepMediaActionBottomSheetState?.copy(durationEnabled = enabled)
@@ -412,9 +423,17 @@ class CreateActionDelegate(
ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward(durationMs)
ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward(durationMs)
ActionId.STEP_FORWARD_PACKAGE ->
- ActionData.ControlMediaForApp.StepForward(state.packageName!!, durationMs)
+ ActionData.ControlMediaForApp.StepForward(
+ state.packageName!!,
+ durationMs,
+ state.appName,
+ )
ActionId.STEP_BACKWARD_PACKAGE ->
- ActionData.ControlMediaForApp.StepBackward(state.packageName!!, durationMs)
+ ActionData.ControlMediaForApp.StepBackward(
+ state.packageName!!,
+ durationMs,
+ state.appName,
+ )
else -> throw Exception("don't know how to create action for ${state.actionId}")
}
@@ -466,7 +485,7 @@ class CreateActionDelegate(
ActionId.REWIND_PACKAGE,
ActionId.STOP_MEDIA_PACKAGE,
-> {
- val packageName =
+ val result =
navigate(
"choose_app_for_media_action",
NavDestination.ChooseApp(allowHiddenApps = true),
@@ -474,28 +493,34 @@ class CreateActionDelegate(
val action = when (actionId) {
ActionId.PAUSE_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.Pause(packageName)
+ ActionData.ControlMediaForApp.Pause(result.packageName, result.appName)
ActionId.PLAY_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.Play(packageName)
+ ActionData.ControlMediaForApp.Play(result.packageName, result.appName)
ActionId.PLAY_PAUSE_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.PlayPause(packageName)
+ ActionData.ControlMediaForApp.PlayPause(result.packageName, result.appName)
ActionId.NEXT_TRACK_PACKAGE ->
- ActionData.ControlMediaForApp.NextTrack(packageName)
+ ActionData.ControlMediaForApp.NextTrack(result.packageName, result.appName)
ActionId.PREVIOUS_TRACK_PACKAGE ->
- ActionData.ControlMediaForApp.PreviousTrack(packageName)
+ ActionData.ControlMediaForApp.PreviousTrack(
+ result.packageName,
+ result.appName,
+ )
ActionId.FAST_FORWARD_PACKAGE ->
- ActionData.ControlMediaForApp.FastForward(packageName)
+ ActionData.ControlMediaForApp.FastForward(
+ result.packageName,
+ result.appName,
+ )
ActionId.REWIND_PACKAGE ->
- ActionData.ControlMediaForApp.Rewind(packageName)
+ ActionData.ControlMediaForApp.Rewind(result.packageName, result.appName)
ActionId.STOP_MEDIA_PACKAGE ->
- ActionData.ControlMediaForApp.Stop(packageName)
+ ActionData.ControlMediaForApp.Stop(result.packageName, result.appName)
else -> throw Exception("don't know how to create action for $actionId")
}
@@ -506,7 +531,7 @@ class CreateActionDelegate(
ActionId.STEP_FORWARD_PACKAGE,
ActionId.STEP_BACKWARD_PACKAGE,
-> {
- val packageName =
+ val result =
navigate(
"choose_app_for_media_action",
NavDestination.ChooseApp(allowHiddenApps = true),
@@ -520,7 +545,8 @@ class CreateActionDelegate(
stepMediaActionBottomSheetState = StepMediaActionBottomSheetState(
actionId = actionId,
- packageName = packageName,
+ packageName = result.packageName,
+ appName = result.appName,
durationEnabled = oldStepDurationMs != null,
durationSeconds = ((oldStepDurationMs ?: 30000L) / 1000).toInt(),
)
@@ -784,14 +810,17 @@ class CreateActionDelegate(
}
ActionId.APP -> {
- val packageName =
+ val result =
navigate(
"choose_app_for_app_action",
NavDestination.ChooseApp(allowHiddenApps = false),
)
?: return null
- return ActionData.App(packageName)
+ return ActionData.App(
+ packageName = result.packageName,
+ savedAppName = result.appName,
+ )
}
ActionId.APP_SHORTCUT -> {
@@ -803,6 +832,7 @@ class CreateActionDelegate(
appShortcutResult.packageName,
appShortcutResult.shortcutName,
appShortcutResult.uri,
+ appShortcutResult.appName,
)
}
@@ -1172,6 +1202,8 @@ class CreateActionDelegate(
ActionId.CYCLE_KEYBOARD_LANGUAGE -> return ActionData.CycleKeyboardLanguage
+ ActionId.CYCLE_KEYBOARD -> return ActionData.CycleKeyboard
+
ActionId.TEXT_CUT -> return ActionData.CutText
ActionId.TEXT_COPY -> return ActionData.CopyText
@@ -1236,6 +1268,12 @@ class CreateActionDelegate(
return null
}
+ ActionId.VIBRATE -> {
+ val oldEffect = (oldData as? ActionData.Vibrate)?.effect
+ openVibrateConfig(oldEffect, DEFAULT_VIBRATE_ACTION_DURATION_MS)
+ 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 68f74a43ad..c7fd7d6c7a 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
@@ -18,6 +18,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -285,7 +286,7 @@ private fun CreateNotificationActionBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun CreateNotificationActionBottomSheetPreview() {
KeyMapperTheme {
@@ -293,6 +294,7 @@ private fun CreateNotificationActionBottomSheetPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
CreateNotificationActionBottomSheet(
@@ -309,7 +311,7 @@ private fun CreateNotificationActionBottomSheetPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun CreateNotificationActionBottomSheetEmptyPreview() {
KeyMapperTheme {
@@ -317,6 +319,7 @@ private fun CreateNotificationActionBottomSheetEmptyPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
CreateNotificationActionBottomSheet(
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 d7d058aa61..1e2b993770 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
@@ -31,6 +31,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -453,7 +454,7 @@ data class ChangeFlashlightStrengthActionState(
)
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewBothLenses() {
KeyMapperTheme {
@@ -461,6 +462,7 @@ private fun PreviewBothLenses() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
EnableFlashlightActionBottomSheet(
@@ -489,7 +491,7 @@ private fun PreviewBothLenses() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewOnlyBackLens() {
KeyMapperTheme {
@@ -497,6 +499,7 @@ private fun PreviewOnlyBackLens() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
EnableFlashlightActionBottomSheet(
@@ -520,7 +523,7 @@ private fun PreviewOnlyBackLens() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewOnlyBackLensChangeStrength() {
KeyMapperTheme {
@@ -528,6 +531,7 @@ private fun PreviewOnlyBackLensChangeStrength() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ChangeFlashlightStrengthActionBottomSheet(
@@ -549,7 +553,7 @@ private fun PreviewOnlyBackLensChangeStrength() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview(apiLevel = Build.VERSION_CODES.R)
+@Preview(apiLevel = Build.VERSION_CODES.R, showSystemUi = true)
@Composable
private fun PreviewUnsupportedAndroidVersion() {
KeyMapperTheme {
@@ -557,6 +561,7 @@ private fun PreviewUnsupportedAndroidVersion() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
EnableFlashlightActionBottomSheet(
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 2ebe6e1d51..27818a788b 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
@@ -19,6 +19,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -291,7 +292,7 @@ private fun HttpRequestBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewEmpty() {
KeyMapperTheme {
@@ -299,6 +300,7 @@ private fun PreviewEmpty() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
HttpRequestBottomSheet(
sheetState = sheetState,
@@ -315,7 +317,7 @@ private fun PreviewEmpty() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewFilled() {
KeyMapperTheme {
@@ -323,6 +325,7 @@ private fun PreviewFilled() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
HttpRequestBottomSheet(
sheetState = sheetState,
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 20fe980ed8..74f5f3584f 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
@@ -18,6 +18,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -308,7 +309,7 @@ private fun ModifySettingActionBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun Preview() {
KeyMapperTheme {
@@ -316,6 +317,7 @@ private fun Preview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ModifySettingActionBottomSheet(
@@ -330,7 +332,7 @@ private fun Preview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewEmpty() {
KeyMapperTheme {
@@ -338,6 +340,7 @@ private fun PreviewEmpty() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ModifySettingActionBottomSheet(
@@ -352,7 +355,7 @@ private fun PreviewEmpty() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewPermissionNotGranted() {
KeyMapperTheme {
@@ -360,6 +363,7 @@ private fun PreviewPermissionNotGranted() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ModifySettingActionBottomSheet(
@@ -375,7 +379,7 @@ private fun PreviewPermissionNotGranted() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewTestLoading() {
KeyMapperTheme {
@@ -383,6 +387,7 @@ private fun PreviewTestLoading() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ModifySettingActionBottomSheet(
@@ -398,7 +403,7 @@ private fun PreviewTestLoading() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewTestSuccess() {
KeyMapperTheme {
@@ -406,6 +411,7 @@ private fun PreviewTestSuccess() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ModifySettingActionBottomSheet(
@@ -421,7 +427,7 @@ private fun PreviewTestSuccess() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewTestError() {
KeyMapperTheme {
@@ -429,6 +435,7 @@ private fun PreviewTestError() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ModifySettingActionBottomSheet(
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 fcbba68c89..6137fe85e7 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.vibration.vibrate
import io.github.sds100.keymapper.common.utils.InputEventAction
import io.github.sds100.keymapper.common.utils.KMError
import io.github.sds100.keymapper.common.utils.KMError.SdkVersionTooLow
@@ -60,8 +61,6 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter
import io.github.sds100.keymapper.system.nfc.NfcAdapter
import io.github.sds100.keymapper.system.notifications.NotificationAdapter
import io.github.sds100.keymapper.system.notifications.NotificationModel
-import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter
-import io.github.sds100.keymapper.system.notifications.NotificationServiceEvent
import io.github.sds100.keymapper.system.phone.PhoneAdapter
import io.github.sds100.keymapper.system.popup.ToastAdapter
import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter
@@ -70,6 +69,7 @@ import io.github.sds100.keymapper.system.settings.SettingType
import io.github.sds100.keymapper.system.settings.SettingsAdapter
import io.github.sds100.keymapper.system.shell.ShellAdapter
import io.github.sds100.keymapper.system.url.OpenUrlAdapter
+import io.github.sds100.keymapper.system.vibrator.VibratorAdapter
import io.github.sds100.keymapper.system.volume.RingerMode
import io.github.sds100.keymapper.system.volume.VolumeAdapter
import io.github.sds100.keymapper.system.volume.VolumeStream
@@ -116,13 +116,13 @@ class PerformActionsUseCaseImpl @AssistedInject constructor(
private val openUrlAdapter: OpenUrlAdapter,
private val resourceProvider: ResourceProvider,
private val soundsManager: SoundsManager,
- private val notificationReceiverAdapter: NotificationReceiverAdapter,
private val notificationAdapter: NotificationAdapter,
private val ringtoneAdapter: RingtoneAdapter,
private val settingsRepository: PreferenceRepository,
private val inputEventHub: InputEventHub,
private val systemBridgeConnectionManager: SystemBridgeConnectionManager,
private val settingsAdapter: SettingsAdapter,
+ private val vibratorAdapter: VibratorAdapter,
) : PerformActionsUseCase {
companion object {
@@ -304,6 +304,23 @@ class PerformActionsUseCaseImpl @AssistedInject constructor(
result = inputMethodAdapter.cycleInputMethodSubtype()
}
+ is ActionData.CycleKeyboard -> {
+ result = inputMethodAdapter.getNextInputMethod().then { nextIme ->
+ switchImeInterface.switchIme(nextIme.id)
+
+ // See issue #1064. Wait for the input method to finish switching before returning.
+ val chosenIme = withTimeoutOrNull(2000) {
+ inputMethodAdapter.chosenIme.filterNotNull().first { it.id == nextIme.id }
+ }
+
+ if (chosenIme == null) {
+ KMError.SwitchImeFailed
+ } else {
+ Success(Unit)
+ }
+ }
+ }
+
is ActionData.Volume.Down -> {
result = audioAdapter.lowerVolume(
stream = action.volumeStream,
@@ -977,17 +994,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor(
}
ActionData.DismissAllNotifications -> {
- result =
- notificationReceiverAdapter.send(
- NotificationServiceEvent.DismissAllNotifications,
- )
+ result = notificationAdapter.dismissAllNotifications()
}
ActionData.DismissLastNotification -> {
- result =
- notificationReceiverAdapter.send(
- NotificationServiceEvent.DismissLastNotification,
- )
+ result = notificationAdapter.dismissLastNotification()
}
is ActionData.CreateNotification -> {
@@ -1019,6 +1030,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor(
result = success()
}
+ is ActionData.Vibrate -> {
+ vibratorAdapter.vibrate(action.effect)
+ result = success()
+ }
+
ActionData.AnswerCall -> {
phoneAdapter.answerCall()
result = success()
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 b6ad399bb4..a9c8e3bd12 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
@@ -20,6 +20,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -326,7 +327,7 @@ private fun SmsActionBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun Preview() {
KeyMapperTheme {
@@ -334,6 +335,7 @@ private fun Preview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
SmsActionBottomSheet(
@@ -349,7 +351,7 @@ private fun Preview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewTestError() {
KeyMapperTheme {
@@ -357,6 +359,7 @@ private fun PreviewTestError() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
SmsActionBottomSheet(
@@ -372,7 +375,7 @@ private fun PreviewTestError() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewTestSuccess() {
KeyMapperTheme {
@@ -380,6 +383,7 @@ private fun PreviewTestSuccess() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
SmsActionBottomSheet(
@@ -395,7 +399,7 @@ private fun PreviewTestSuccess() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewEmpty() {
KeyMapperTheme {
@@ -403,6 +407,7 @@ private fun PreviewEmpty() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
SmsActionBottomSheet(
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 b768f34a1c..80c60f48c4 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
@@ -15,6 +15,7 @@ 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
@@ -39,6 +40,7 @@ private const val DURATION_STEP_SECONDS = 5
data class StepMediaActionBottomSheetState(
val actionId: ActionId,
val packageName: String? = null,
+ val appName: String? = null,
val durationEnabled: Boolean = false,
/**
* UI works with seconds for user-friendliness
@@ -173,7 +175,7 @@ private fun StepMediaActionBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun StepMediaActionBottomSheetPreview() {
KeyMapperTheme {
@@ -181,6 +183,7 @@ private fun StepMediaActionBottomSheetPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
StepMediaActionBottomSheet(
@@ -195,7 +198,7 @@ private fun StepMediaActionBottomSheetPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun StepMediaActionBottomSheetDefaultPreview() {
KeyMapperTheme {
@@ -203,6 +206,7 @@ private fun StepMediaActionBottomSheetDefaultPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
StepMediaActionBottomSheet(
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 2a0d9a9c56..7e766f483f 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
@@ -16,6 +16,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -171,7 +172,7 @@ private fun ToastActionBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun ToastActionBottomSheetPreview() {
KeyMapperTheme {
@@ -179,6 +180,7 @@ private fun ToastActionBottomSheetPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ToastActionBottomSheet(
sheetState = sheetState,
@@ -191,7 +193,7 @@ private fun ToastActionBottomSheetPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun ToastActionBottomSheetEmptyPreview() {
KeyMapperTheme {
@@ -199,6 +201,7 @@ private fun ToastActionBottomSheetEmptyPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
ToastActionBottomSheet(
sheetState = sheetState,
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
new file mode 100644
index 0000000000..3fe3e3700d
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt
@@ -0,0 +1,156 @@
+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.VibrationEffectMode
+import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectPickerContent
+import io.github.sds100.keymapper.base.vibration.VibrateConfigState
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun VibrateActionBottomSheet(delegate: CreateActionDelegate) {
+ val scope = rememberCoroutineScope()
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ val state = delegate.vibrateConfigState
+
+ if (state != null) {
+ VibrateActionBottomSheet(
+ sheetState = sheetState,
+ state = state,
+ onDismissRequest = delegate::closeVibrateConfig,
+ onModeChange = delegate::onVibrateModeChange,
+ onDurationChange = delegate::onVibrateDurationChange,
+ onPredefinedTypeChange = delegate::onVibratePredefinedTypeChange,
+ onDoneClick = {
+ scope.launch {
+ sheetState.hide()
+ delegate.onDoneVibrateClick()
+ }
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun VibrateActionBottomSheet(
+ sheetState: SheetState,
+ state: VibrateConfigState,
+ onDismissRequest: () -> Unit = {},
+ onModeChange: (VibrationEffectMode) -> Unit = {},
+ onDurationChange: (Int) -> Unit = {},
+ onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit = {},
+ onDoneClick: () -> Unit = {},
+) {
+ val scope = rememberCoroutineScope()
+
+ 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_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))
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showSystemUi = true)
+@Composable
+private fun VibrateActionBottomSheetPreview() {
+ KeyMapperTheme {
+ val sheetState = SheetState(
+ skipPartiallyExpanded = true,
+ positionalThreshold = { 0f },
+ velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
+ )
+
+ VibrateActionBottomSheet(
+ sheetState = sheetState,
+ state = VibrateConfigState(durationMs = 200, defaultDurationMs = 200),
+ )
+ }
+}
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 959951b03c..28e27bc03c 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
@@ -19,6 +19,7 @@ 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
@@ -200,7 +201,7 @@ private fun VolumeActionBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview(heightDp = 400)
+@Preview(heightDp = 400, showSystemUi = true)
@Composable
private fun PreviewVolumeActionBottomSheet() {
KeyMapperTheme {
@@ -208,6 +209,7 @@ private fun PreviewVolumeActionBottomSheet() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
var state by remember {
@@ -232,7 +234,7 @@ private fun PreviewVolumeActionBottomSheet() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewVolumeActionBottomSheetDefaultStream() {
KeyMapperTheme {
@@ -240,6 +242,7 @@ private fun PreviewVolumeActionBottomSheetDefaultStream() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
var state by remember {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt
index 81ca3c2fc1..7bce08f242 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt
@@ -30,6 +30,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SheetState
+import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -296,7 +297,7 @@ private fun FixKeyEventActionOptionCard(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun InputMethodPreview() {
KeyMapperTheme {
@@ -304,6 +305,7 @@ private fun InputMethodPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
FixKeyEventActionBottomSheet(
@@ -321,7 +323,7 @@ private fun InputMethodPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun ExpertModePreview() {
KeyMapperTheme {
@@ -329,6 +331,7 @@ private fun ExpertModePreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
FixKeyEventActionBottomSheet(
@@ -342,7 +345,7 @@ private fun ExpertModePreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun ExpertModeUnsupportedPreview() {
KeyMapperTheme {
@@ -350,6 +353,7 @@ private fun ExpertModeUnsupportedPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
FixKeyEventActionBottomSheet(
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt
index 9142583a93..32bbe1f439 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt
@@ -170,7 +170,7 @@ private fun TalkBackGestureItem(
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewPickTalkBackGestureDialog() {
KeyMapperTheme {
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 5b6f935c88..8c5cee4f1f 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
@@ -33,6 +33,7 @@ import io.github.sds100.keymapper.data.PreferenceDefaults
import io.github.sds100.keymapper.data.db.AppDatabase
import io.github.sds100.keymapper.data.entities.ActionEntity
import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity
import io.github.sds100.keymapper.data.entities.EntityExtra
import io.github.sds100.keymapper.data.entities.FingerprintMapEntity
import io.github.sds100.keymapper.data.entities.FloatingButtonEntity
@@ -119,6 +120,7 @@ class BackupManagerImpl @Inject constructor(
.registerTypeAdapter(ActionEntity.DESERIALIZER)
.registerTypeAdapter(EntityExtra.DESERIALIZER)
.registerTypeAdapter(ConstraintEntity.DESERIALIZER)
+ .registerTypeAdapter(ConstraintGroupEntity.DESERIALIZER)
.registerTypeAdapter(FloatingLayoutEntity.DESERIALIZER)
.registerTypeAdapter(FloatingButtonEntity.DESERIALIZER)
.registerTypeAdapter(GroupEntity.DESERIALIZER)
@@ -260,6 +262,11 @@ class BackupManagerImpl @Inject constructor(
// Do nothing. Just added columns to floating button entity.
JsonMigration(21, 22) { json -> json },
+
+ // Do nothing. Adds the constraint_groups column, which defaults to empty.
+ // 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 },
)
if (keyMapListJsonArray != null) {
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 2367aa378f..175a242a40 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
@@ -68,6 +68,7 @@ class ChooseConstraintViewModel @Inject constructor(
ConstraintCategory.PHONE,
ConstraintCategory.POWER,
ConstraintCategory.DEVICE,
+ ConstraintCategory.NOTIFICATIONS,
ConstraintCategory.TIME,
)
}
@@ -182,25 +183,17 @@ class ChooseConstraintViewModel @Inject constructor(
when (val constraintType = ConstraintId.valueOf(id)) {
ConstraintId.APP_IN_FOREGROUND,
- ConstraintId.APP_NOT_IN_FOREGROUND,
ConstraintId.APP_PLAYING_MEDIA,
- ConstraintId.APP_NOT_PLAYING_MEDIA,
-> onSelectAppConstraint(constraintType)
ConstraintId.MEDIA_PLAYING -> returnResult.emit(ConstraintData.MediaPlaying)
- ConstraintId.MEDIA_NOT_PLAYING -> returnResult.emit(ConstraintData.NoMediaPlaying)
+ ConstraintId.NOTIFICATION_POSTED -> onSelectNotificationConstraint()
- ConstraintId.BT_DEVICE_CONNECTED,
- ConstraintId.BT_DEVICE_DISCONNECTED,
- -> onSelectBluetoothConstraint(
- constraintType,
- )
+ ConstraintId.BT_DEVICE_CONNECTED -> onSelectBluetoothConstraint()
ConstraintId.SCREEN_ON -> returnResult.emit(ConstraintData.ScreenOn)
- ConstraintId.SCREEN_OFF -> returnResult.emit(ConstraintData.ScreenOff)
-
ConstraintId.DISPLAY_RESOLUTION -> {
displayResolutionState = buildDisplayResolutionState()
}
@@ -264,37 +257,18 @@ class ChooseConstraintViewModel @Inject constructor(
returnResult.emit(ConstraintData.FlashlightOn(lens = lens))
}
- ConstraintId.FLASHLIGHT_OFF -> {
- val lens = chooseFlashlightLens() ?: return@launch
- returnResult.emit(ConstraintData.FlashlightOff(lens = lens))
- }
-
ConstraintId.WIFI_ON -> returnResult.emit(ConstraintData.WifiOn)
- ConstraintId.WIFI_OFF -> returnResult.emit(ConstraintData.WifiOff)
+ ConstraintId.WIFI_CONNECTED -> onSelectWifiConnectedConstraint()
- ConstraintId.WIFI_CONNECTED,
- ConstraintId.WIFI_DISCONNECTED,
- -> onSelectWifiConnectedConstraint(
- constraintType,
- )
-
- ConstraintId.IME_CHOSEN,
- ConstraintId.IME_NOT_CHOSEN,
- -> onSelectImeChosenConstraint(constraintType)
+ ConstraintId.IME_CHOSEN -> onSelectImeChosenConstraint()
ConstraintId.KEYBOARD_SHOWING ->
returnResult.emit(ConstraintData.KeyboardShowing)
- ConstraintId.KEYBOARD_NOT_SHOWING ->
- returnResult.emit(ConstraintData.KeyboardNotShowing)
-
ConstraintId.DEVICE_IS_LOCKED ->
returnResult.emit(ConstraintData.DeviceIsLocked)
- ConstraintId.DEVICE_IS_UNLOCKED ->
- returnResult.emit(ConstraintData.DeviceIsUnlocked)
-
ConstraintId.IN_PHONE_CALL ->
returnResult.emit(ConstraintData.InPhoneCall)
@@ -316,9 +290,6 @@ class ChooseConstraintViewModel @Inject constructor(
ConstraintId.CHARGING ->
returnResult.emit(ConstraintData.Charging)
- ConstraintId.DISCHARGING ->
- returnResult.emit(ConstraintData.Discharging)
-
ConstraintId.HINGE_CLOSED ->
returnResult.emit(ConstraintData.HingeClosed)
@@ -328,15 +299,9 @@ class ChooseConstraintViewModel @Inject constructor(
ConstraintId.LOCK_SCREEN_SHOWING ->
returnResult.emit(ConstraintData.LockScreenShowing)
- ConstraintId.LOCK_SCREEN_NOT_SHOWING ->
- returnResult.emit(ConstraintData.LockScreenNotShowing)
-
ConstraintId.NOTIFICATION_PANEL_SHOWING ->
returnResult.emit(ConstraintData.NotificationPanelShowing)
- ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING ->
- returnResult.emit(ConstraintData.NotificationPanelNotShowing)
-
ConstraintId.TIME -> {
timeConstraintState = ConstraintData.Time(
startHour = 0,
@@ -542,7 +507,7 @@ class ChooseConstraintViewModel @Inject constructor(
}
}
- private suspend fun onSelectWifiConnectedConstraint(type: ConstraintId) {
+ private suspend fun onSelectWifiConnectedConstraint() {
val knownSSIDs: List = useCase.getKnownWiFiSSIDs()
val chosenSSID: String?
@@ -571,18 +536,10 @@ class ChooseConstraintViewModel @Inject constructor(
useCase.saveWifiSSID(chosenSSID)
}
- when (type) {
- ConstraintId.WIFI_CONNECTED ->
- returnResult.emit(ConstraintData.WifiConnected(ssid = chosenSSID))
-
- ConstraintId.WIFI_DISCONNECTED ->
- returnResult.emit(ConstraintData.WifiDisconnected(ssid = chosenSSID))
-
- else -> Unit
- }
+ returnResult.emit(ConstraintData.WifiConnected(ssid = chosenSSID))
}
- private suspend fun onSelectImeChosenConstraint(type: ConstraintId) {
+ private suspend fun onSelectImeChosenConstraint() {
val inputMethods = useCase.getEnabledInputMethods()
val items = inputMethods.map { it.id to it.label }
val dialog = DialogModel.SingleChoice(items = items)
@@ -591,28 +548,15 @@ class ChooseConstraintViewModel @Inject constructor(
val imeInfo = inputMethods.single { it.id == result }
- when (type) {
- ConstraintId.IME_CHOSEN ->
- returnResult.emit(
- ConstraintData.ImeChosen(
- imeId = imeInfo.id,
- imeLabel = imeInfo.label,
- ),
- )
-
- ConstraintId.IME_NOT_CHOSEN ->
- returnResult.emit(
- ConstraintData.ImeNotChosen(
- imeId = imeInfo.id,
- imeLabel = imeInfo.label,
- ),
- )
-
- else -> Unit
- }
+ returnResult.emit(
+ ConstraintData.ImeChosen(
+ imeId = imeInfo.id,
+ imeLabel = imeInfo.label,
+ ),
+ )
}
- private suspend fun onSelectBluetoothConstraint(type: ConstraintId) {
+ private suspend fun onSelectBluetoothConstraint() {
val response = showDialog(
"bluetooth_device_constraint_limitation",
DialogModel.Ok(getString(R.string.dialog_message_bt_constraint_limitation)),
@@ -625,27 +569,25 @@ class ChooseConstraintViewModel @Inject constructor(
NavDestination.ChooseBluetoothDevice,
) ?: return
- val constraintData = when (type) {
- ConstraintId.BT_DEVICE_CONNECTED -> ConstraintData.BtDeviceConnected(
- bluetoothAddress = device.address,
- deviceName = device.name,
- )
-
- ConstraintId.BT_DEVICE_DISCONNECTED -> ConstraintData.BtDeviceDisconnected(
+ returnResult.emit(
+ ConstraintData.BtDeviceConnected(
bluetoothAddress = device.address,
deviceName = device.name,
- )
+ ),
+ )
+ }
- else -> throw IllegalArgumentException(
- "Don't know how to create $type constraint after choosing app",
- )
- }
+ private suspend fun onSelectNotificationConstraint() {
+ val constraintData = navigate(
+ "config_notification_constraint",
+ NavDestination.ConfigNotificationConstraint,
+ ) ?: return
returnResult.emit(constraintData)
}
private suspend fun onSelectAppConstraint(type: ConstraintId) {
- val packageName =
+ val result =
navigate(
"choose_package_for_constraint",
NavDestination.ChooseApp(allowHiddenApps = true),
@@ -654,19 +596,13 @@ class ChooseConstraintViewModel @Inject constructor(
val constraintData = when (type) {
ConstraintId.APP_IN_FOREGROUND -> ConstraintData.AppInForeground(
- packageName = packageName,
- )
-
- ConstraintId.APP_NOT_IN_FOREGROUND -> ConstraintData.AppNotInForeground(
- packageName = packageName,
+ packageName = result.packageName,
+ appName = result.appName,
)
ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia(
- packageName = packageName,
- )
-
- ConstraintId.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia(
- packageName = packageName,
+ packageName = result.packageName,
+ appName = result.appName,
)
else -> throw IllegalArgumentException(
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 fc2f113302..c687b32380 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
@@ -4,6 +4,7 @@ import dagger.hilt.android.scopes.ViewModelScoped
import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState
import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.common.utils.State
+import io.github.sds100.keymapper.common.utils.dataOrNull
import io.github.sds100.keymapper.data.Keys
import io.github.sds100.keymapper.data.repositories.PreferenceRepository
import java.util.LinkedList
@@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonArray
@ViewModelScoped
class ConfigConstraintsUseCaseImpl @Inject constructor(
@@ -35,66 +37,164 @@ class ConfigConstraintsUseCaseImpl @Inject constructor(
// Do not include constraints that the key map already contains.
shortcutData
.filter { constraintData ->
- !keyMap.data.constraintState.constraints.any { it.data == constraintData }
+ !keyMap.data.constraintState.allConstraints.any { it.data == constraintData }
}
.take(5)
}
- override fun addConstraint(constraintData: ConstraintData): Boolean {
+ override fun addConstraint(
+ groupUid: String?,
+ constraintData: ConstraintData,
+ ): ConstraintGroup? {
var containsConstraint = false
+ var affectedGroup: ConstraintGroup? = null
val newConstraint = Constraint(data = constraintData)
- updateConstraintState { oldState ->
- containsConstraint = oldState.constraints.any { it.data == constraintData }
+ val newKeyMap = updateConstraintState { oldState ->
+ val group = oldState.groups.find { it.uid == groupUid }
+
+ if (group == null) {
+ val newGroup = ConstraintGroup(constraints = listOf(newConstraint))
+
+ // Use OR mode by default when there are multiple groups. I think this is the
+ // most likely use case because if you want multiple constraints to match with AND
+ // you will put them in the same group.
+ val mode = if (oldState.groups.size <= 1) {
+ ConstraintMode.OR
+ } else {
+ oldState.mode
+ }
+
+ affectedGroup = newGroup
+
+ return@updateConstraintState oldState.copy(
+ groups = oldState.groups.plus(newGroup),
+ mode = mode,
+ )
+ }
+
+ containsConstraint =
+ group.constraints.any { it.data == constraintData && !it.isNot }
if (containsConstraint) {
oldState
} else {
- oldState.copy(constraints = oldState.constraints.plus(newConstraint))
+ oldState.updateGroup(group.uid) { group ->
+ affectedGroup = group
+
+ group.copy(constraints = group.constraints.plus(newConstraint))
+ }
}
}
- preferenceRepository.update(
- Keys.recentlyUsedConstraints,
- { old ->
- val oldDataList = getConstraintShortcuts(old)
+ if (newKeyMap == null) {
+ return null
+ }
+
+ preferenceRepository.update(Keys.recentlyUsedConstraints) { old ->
+ val oldDataList = getConstraintShortcuts(old)
+
+ val newDataList = LinkedList(oldDataList)
+ .apply { addFirst(constraintData) }
+ .distinct()
+
+ Json.encodeToString(newDataList)
+ }
+
+ return if (containsConstraint) {
+ null
+ } else {
+ affectedGroup
+ }
+ }
- val newDataList = LinkedList(oldDataList)
- .apply { addFirst(constraintData) }
- .distinct()
+ override fun removeConstraint(uid: String) {
+ updateConstraintState { oldState ->
+ val groups = oldState.groups
+ .map { group ->
+ group.copy(constraints = group.constraints.filterNot { it.uid == uid })
+ }
+ .filter { it.constraints.isNotEmpty() }
- Json.encodeToString(newDataList)
- },
- )
+ oldState.copy(groups = groups)
+ }
+ }
- return !containsConstraint
+ override fun removeGroup(groupUid: String) {
+ updateConstraintState { oldState ->
+ oldState.copy(groups = oldState.groups.filterNot { it.uid == groupUid })
+ }
}
- override fun removeConstraint(id: String) {
+ override fun toggleNot(constraintUid: String) {
updateConstraintState { oldState ->
- val newList = oldState.constraints.toMutableSet().apply {
- removeAll { it.uid == id }
+ val groups = oldState.groups.map { group ->
+ val constraints = group.constraints.map { constraint ->
+ if (constraint.uid == constraintUid) {
+ constraint.copy(isNot = !constraint.isNot)
+ } else {
+ constraint
+ }
+ }
+
+ group.copy(constraints = constraints)
}
- oldState.copy(constraints = newList)
+
+ oldState.copy(groups = groups)
}
}
- override fun setAndMode() {
+ override fun setMode(mode: ConstraintMode) {
updateConstraintState { oldState ->
- oldState.copy(mode = ConstraintMode.AND)
+ oldState.copy(mode = mode)
}
}
- override fun setOrMode() {
+ override fun setGroupMode(groupUid: String, mode: ConstraintMode) {
updateConstraintState { oldState ->
- oldState.copy(mode = ConstraintMode.OR)
+ oldState.updateGroup(groupUid) { it.copy(mode = mode) }
}
}
- private fun updateConstraintState(block: (ConstraintState) -> ConstraintState) {
- state.update { keyMap ->
+ override fun setGroupName(groupUid: String, name: String?) {
+ updateConstraintState { oldState ->
+ oldState.updateGroup(groupUid) { it.copy(name = name?.trim()?.ifBlank { null }) }
+ }
+ }
+
+ override fun moveGroup(fromIndex: Int, toIndex: Int) {
+ updateConstraintState { oldState ->
+ oldState.copy(groups = oldState.groups.move(fromIndex, toIndex))
+ }
+ }
+
+ private fun updateConstraintState(block: (ConstraintState) -> ConstraintState): KeyMap? {
+ return state.update { keyMap ->
keyMap.copy(constraintState = block(keyMap.constraintState))
+ }.dataOrNull()
+ }
+
+ private fun ConstraintState.updateGroup(
+ groupUid: String,
+ block: (ConstraintGroup) -> ConstraintGroup,
+ ): ConstraintState {
+ val groups = groups.map { group ->
+ if (group.uid == groupUid) {
+ block(group)
+ } else {
+ group
+ }
}
+
+ return copy(groups = groups)
+ }
+
+ private fun List.move(fromIndex: Int, toIndex: Int): List {
+ if (fromIndex !in indices || toIndex !in indices) {
+ return this
+ }
+
+ return toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
}
private fun getConstraintShortcuts(json: String?): List {
@@ -103,7 +203,17 @@ class ConfigConstraintsUseCaseImpl @Inject constructor(
}
try {
- return Json.decodeFromString>(json).distinct()
+ // Decode each constraint separately so constraints that no longer exist are skipped
+ // without losing the other shortcuts.
+ return Json.parseToJsonElement(json).jsonArray
+ .mapNotNull { element ->
+ try {
+ Json.decodeFromJsonElement(ConstraintData.serializer(), element)
+ } catch (_: Exception) {
+ null
+ }
+ }
+ .distinct()
} catch (_: Exception) {
return emptyList()
}
@@ -114,8 +224,22 @@ interface ConfigConstraintsUseCase {
val keyMap: StateFlow>
val recentlyUsedConstraints: Flow>
- fun addConstraint(constraintData: ConstraintData): Boolean
- fun removeConstraint(id: String)
- fun setAndMode()
- fun setOrMode()
+
+ /**
+ * @param groupUid the group to add the constraint to. A new group is created if this is null.
+ * @return the group the constraint was added to (a newly created group, or an existing
+ * group), or null if the group already contains the constraint (a duplicate).
+ */
+ fun addConstraint(groupUid: String?, constraintData: ConstraintData): ConstraintGroup?
+ fun removeConstraint(uid: String)
+ fun removeGroup(groupUid: String)
+ fun toggleNot(constraintUid: String)
+
+ /**
+ * Set the mode that combines the groups.
+ */
+ fun setMode(mode: ConstraintMode)
+ fun setGroupMode(groupUid: String, mode: ConstraintMode)
+ fun setGroupName(groupUid: String, name: String?)
+ fun moveGroup(fromIndex: Int, toIndex: Int)
}
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 46ed17f0dd..d0fa71f44f 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
@@ -6,6 +6,7 @@ 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.keymaps.ShortcutModel
import io.github.sds100.keymapper.base.utils.getFullMessage
import io.github.sds100.keymapper.base.utils.isFixable
@@ -15,7 +16,6 @@ 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.utils.ui.compose.ComposeIconInfo
import io.github.sds100.keymapper.common.utils.KMError
import io.github.sds100.keymapper.common.utils.State
import io.github.sds100.keymapper.common.utils.dataOrNull
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
@@ -70,40 +71,82 @@ class ConfigConstraintsViewModel @Inject constructor(
var showDuplicateConstraintsSnackbar: Boolean by mutableStateOf(false)
+ private val expandedGroups: MutableStateFlow> = MutableStateFlow(emptySet())
+ private val knownGroupUids: MutableSet = mutableSetOf()
+
init {
+ viewModelScope.launch {
+ config.keyMap
+ .map { state -> state.dataOrNull()?.constraintState?.groups }
+ .filterNotNull()
+ .collect { groups ->
+ val errorSnapshot = displayConstraint.constraintErrorSnapshot.first()
+ val newExpandedGroups = mutableSetOf()
+
+ for (group in groups) {
+ // Only expand a group automatically if it is the first time it has
+ // been seen.
+ if (!knownGroupUids.contains(group.uid) &&
+ group.constraints.any { errorSnapshot.getError(it) != null }
+ ) {
+ newExpandedGroups.add(group.uid)
+ }
+ }
+
+ // Expand all the groups at once and not one-by-one
+ expandedGroups.update { set -> set.plus(newExpandedGroups) }
+
+ knownGroupUids.addAll(groups.map { it.uid })
+ }
+ }
+
combine(
config.keyMap,
shortcuts,
constraintErrorSnapshot.filterNotNull(),
- ) { keyMapState, shortcuts, errorSnapshot ->
+ expandedGroups,
+ ) { keyMapState, shortcuts, errorSnapshot, expandedGroups ->
_state.value = keyMapState.mapData { keyMap ->
- buildState(keyMap.constraintState, shortcuts, errorSnapshot)
+ buildState(keyMap.constraintState, shortcuts, errorSnapshot, expandedGroups)
}
}.launchIn(viewModelScope)
}
fun onClickShortcut(constraintData: ConstraintData) {
viewModelScope.launch {
- config.addConstraint(constraintData)
+ val group = config.addConstraint(groupUid = null, constraintData)
+
+ if (group != null) {
+ expandedGroups.update { set -> set.plus(group.uid) }
+ }
}
}
fun onRemoveClick(id: String) = config.removeConstraint(id)
- fun onSelectMode(mode: ConstraintMode) {
- when (mode) {
- ConstraintMode.AND -> config.setAndMode()
- ConstraintMode.OR -> config.setOrMode()
- }
+ fun onRemoveGroupClick(groupUid: String) = config.removeGroup(groupUid)
+
+ fun onNotClick(constraintUid: String) = config.toggleNot(constraintUid)
+
+ fun onSelectMode(mode: ConstraintMode) = config.setMode(mode)
+
+ fun onSelectGroupMode(groupUid: String, mode: ConstraintMode) {
+ config.setGroupMode(groupUid, mode)
}
+ fun onRenameGroup(groupUid: String, name: String) {
+ config.setGroupName(groupUid, name)
+ }
+
+ fun onMoveGroup(fromIndex: Int, toIndex: Int) = config.moveGroup(fromIndex, toIndex)
+
fun onFixError(constraintUid: String) {
viewModelScope.launch {
val constraint = config.keyMap
.firstOrNull()
?.dataOrNull()
?.constraintState
- ?.constraints
+ ?.allConstraints
?.find { it.uid == constraintUid }
?: return@launch
@@ -133,20 +176,33 @@ class ConfigConstraintsViewModel @Inject constructor(
}
}
- fun addConstraint() {
+ /**
+ * @param groupUid the group to add the constraint to. A new group is created if this is null.
+ */
+ fun addConstraint(groupUid: String?) {
viewModelScope.launch {
- val constraint: ConstraintData =
+ val constraintData: ConstraintData =
navigate("add_constraint", NavDestination.ChooseConstraint)
?: return@launch
- val isDuplicate = !config.addConstraint(constraint)
+ val group = config.addConstraint(groupUid, constraintData)
- if (isDuplicate) {
+ if (group == null) {
showDuplicateConstraintsSnackbar = true
+ } else {
+ expandedGroups.update { set -> set.plus(group.uid) }
}
}
}
+ fun onExpandedChange(groupUid: String, expanded: Boolean) {
+ if (expanded) {
+ expandedGroups.update { set -> set.plus(groupUid) }
+ } else {
+ expandedGroups.update { set -> set.minus(groupUid) }
+ }
+ }
+
private fun buildShortcutFromData(
constraintData: ConstraintData,
): ShortcutModel {
@@ -162,35 +218,48 @@ class ConfigConstraintsViewModel @Inject constructor(
state: ConstraintState,
shortcuts: Set>,
errorSnapshot: ConstraintErrorSnapshot,
+ expandedGroups: Set,
): ConfigConstraintsState {
- if (state.constraints.isEmpty()) {
+ if (state.allConstraints.isEmpty()) {
return ConfigConstraintsState.Empty(shortcuts)
}
- val constraintList = state.constraints.mapIndexed { index, constraint ->
- val title: String = uiHelper.getTitle(constraint)
- val icon: ComposeIconInfo = uiHelper.getIcon(constraint)
- val error: KMError? = errorSnapshot.getError(constraint)
+ val groups = state.groups
+ .filter { it.constraints.isNotEmpty() }
+ .map { group ->
+ val constraints = group.constraints.map { constraint ->
+ val error: KMError? = errorSnapshot.getError(constraint)
- ConstraintListItemModel(
- id = constraint.uid,
- icon = icon,
- constraintModeLink = if (state.constraints.size > 1 &&
- index < state.constraints.size - 1
- ) {
- state.mode
- } else {
- null
- },
- text = title,
- error = error?.getFullMessage(this),
- isErrorFixable = error?.isFixable ?: true,
- )
- }
+ ConstraintListItemModel(
+ id = constraint.uid,
+ icon = uiHelper.getIcon(constraint),
+ text = uiHelper.getTitle(constraint),
+ isNot = constraint.isNot,
+ error = error?.getFullMessage(this),
+ isErrorFixable = error?.isFixable ?: true,
+ )
+ }
+
+ val modeWord = when (group.mode) {
+ ConstraintMode.AND -> getString(R.string.constraint_mode_and)
+ ConstraintMode.OR -> getString(R.string.constraint_mode_or)
+ }
+
+ ConstraintGroupListItemModel(
+ uid = group.uid,
+ name = group.name,
+ mode = group.mode,
+ constraints = constraints,
+ description = group.constraints.joinToString(separator = " $modeWord ") {
+ uiHelper.getTitle(it)
+ },
+ isExpanded = expandedGroups.contains(group.uid),
+ )
+ }
return ConfigConstraintsState.Loaded(
- constraintList = constraintList,
- selectedMode = state.mode,
+ groups = groups,
+ mode = state.mode,
shortcuts = shortcuts,
)
}
@@ -201,8 +270,11 @@ sealed class ConfigConstraintsState {
ConfigConstraintsState()
data class Loaded(
- val constraintList: List,
- val selectedMode: ConstraintMode,
+ val groups: List,
+ /**
+ * The mode that combines the groups.
+ */
+ val mode: ConstraintMode,
val shortcuts: Set> = emptySet(),
) : ConfigConstraintsState()
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt
new file mode 100644
index 0000000000..a23ff63d16
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt
@@ -0,0 +1,241 @@
+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.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+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.rounded.ArrowBack
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+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.KeyMapperSegmentedButtonRow
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ConfigNotificationConstraintScreen(
+ modifier: Modifier = Modifier,
+ viewModel: ConfigNotificationConstraintViewModel,
+) {
+ ConfigNotificationConstraintScreen(
+ modifier = modifier,
+ field = viewModel.selectedField,
+ matchMode = viewModel.matchMode,
+ value = viewModel.value,
+ appName = viewModel.selectedAppName,
+ isValid = viewModel.isValid,
+ onSelectField = viewModel::onSelectField,
+ onSelectMatchMode = viewModel::onSelectMatchMode,
+ onValueChange = viewModel::onValueChange,
+ onChooseAppClick = viewModel::onChooseAppClick,
+ onDoneClick = viewModel::onDoneClick,
+ onNavigateBack = viewModel::onNavigateBack,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ConfigNotificationConstraintScreen(
+ modifier: Modifier = Modifier,
+ field: NotificationField = NotificationField.TITLE,
+ matchMode: TextMatchMode = TextMatchMode.CONTAINS,
+ value: String = "",
+ appName: String? = null,
+ isValid: Boolean = false,
+ onSelectField: (NotificationField) -> Unit = {},
+ onSelectMatchMode: (TextMatchMode) -> Unit = {},
+ onValueChange: (String) -> Unit = {},
+ onChooseAppClick: () -> Unit = {},
+ onDoneClick: () -> Unit = {},
+ onNavigateBack: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.constraint_notification_screen_title)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.action_go_back),
+ )
+ }
+ },
+ )
+ },
+ bottomBar = {
+ // Grows by the height of the keyboard so that the content above it shrinks and the
+ // focused text field is scrolled into view.
+ BottomAppBar(
+ modifier = Modifier.imePadding(),
+ actions = {
+ Spacer(modifier = Modifier.weight(1f))
+
+ OutlinedButton(onClick = onNavigateBack) {
+ Text(stringResource(R.string.neg_cancel))
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Button(onClick = onDoneClick, enabled = isValid) {
+ Text(stringResource(R.string.pos_done))
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+ },
+ )
+ },
+ ) { innerPadding ->
+ val layoutDirection = LocalLayoutDirection.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ top = innerPadding.calculateTopPadding(),
+ bottom = innerPadding.calculateBottomPadding(),
+ start = innerPadding.calculateStartPadding(layoutDirection),
+ end = innerPadding.calculateEndPadding(layoutDirection),
+ )
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SectionHeader(stringResource(R.string.constraint_notification_field_header))
+
+ KeyMapperSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth(),
+ buttonStates = NotificationField.entries.map {
+ it to stringResource(ConstraintUtils.getNotificationFieldLabel(it))
+ },
+ selectedState = field,
+ onStateSelected = onSelectField,
+ )
+
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.constraint_notification_sensitive_content_note),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+
+ if (field.isFreeText) {
+ SectionHeader(
+ stringResource(R.string.constraint_notification_match_mode_header),
+ )
+
+ KeyMapperSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth(),
+ buttonStates = TextMatchMode.entries.map {
+ it to stringResource(ConstraintUtils.getTextMatchModeLabel(it))
+ },
+ selectedState = matchMode,
+ onStateSelected = onSelectMatchMode,
+ )
+
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = value,
+ onValueChange = onValueChange,
+ label = {
+ val text = when (field) {
+ NotificationField.TEXT -> stringResource(
+ R.string.constraint_notification_value_hint_text,
+ )
+
+ else -> stringResource(
+ R.string.constraint_notification_value_hint_title,
+ )
+ }
+ Text(text)
+ },
+ singleLine = true,
+ )
+ } else {
+ SectionHeader(stringResource(R.string.constraint_notification_value_header))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (appName != null) {
+ Text(
+ text = appName,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+
+ Spacer(Modifier.weight(1f))
+
+ FilledTonalButton(onClick = onChooseAppClick) {
+ Text(stringResource(R.string.constraint_notification_choose_app))
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+}
+
+@Preview
+@Composable
+private fun TitlePreview() {
+ KeyMapperTheme {
+ ConfigNotificationConstraintScreen(
+ field = NotificationField.TITLE,
+ value = "New message",
+ isValid = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun AppPreview() {
+ KeyMapperTheme {
+ ConfigNotificationConstraintScreen(
+ field = NotificationField.PACKAGE,
+ value = "com.termux",
+ appName = "Termux",
+ isValid = true,
+ )
+ }
+}
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
new file mode 100644
index 0000000000..46fe472911
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt
@@ -0,0 +1,121 @@
+package io.github.sds100.keymapper.base.constraints
+
+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.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.ResourceProvider
+import io.github.sds100.keymapper.common.utils.handle
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+
+@HiltViewModel
+class ConfigNotificationConstraintViewModel @Inject constructor(
+ private val displayConstraintUseCase: DisplayConstraintUseCase,
+ resourceProvider: ResourceProvider,
+ navigationProvider: NavigationProvider,
+) : ViewModel(),
+ ResourceProvider by resourceProvider,
+ NavigationProvider by navigationProvider {
+
+ var selectedField: NotificationField by mutableStateOf(NotificationField.TITLE)
+ private set
+
+ var matchMode: TextMatchMode by mutableStateOf(TextMatchMode.CONTAINS)
+ private set
+
+ var value: String by mutableStateOf("")
+ private set
+
+ var isEditing: Boolean by mutableStateOf(false)
+ private set
+
+ val isValid: Boolean
+ get() = value.isNotEmpty()
+
+ /**
+ * The name of the chosen app, or null when no app has been chosen yet.
+ */
+ val selectedAppName: String?
+ get() = if (selectedField == NotificationField.PACKAGE && value.isNotEmpty()) {
+ displayConstraintUseCase.getAppName(value).handle(
+ onSuccess = { it },
+ onError = { value },
+ )
+ } else {
+ null
+ }
+
+ fun onSelectField(field: NotificationField) {
+ selectedField = field
+
+ // An app is picked from a list so it can only ever be matched exactly. Going back to a text
+ // field starts from the default rather than keeping whatever was used last.
+ matchMode = TextMatchMode.CONTAINS
+
+ value = ""
+ }
+
+ fun onSelectMatchMode(matchMode: TextMatchMode) {
+ this.matchMode = matchMode
+ }
+
+ fun onValueChange(value: String) {
+ this.value = value
+ }
+
+ fun onChooseAppClick() {
+ viewModelScope.launch {
+ val result = navigate(
+ "choose_app_for_notification_constraint",
+ NavDestination.ChooseApp(allowHiddenApps = true),
+ ) ?: return@launch
+
+ value = result.packageName
+ }
+ }
+
+ fun onDoneClick() {
+ val constraint = buildConstraintOrNull() ?: return
+
+ viewModelScope.launch {
+ popBackStackWithResult(Json.encodeToString(constraint))
+ }
+ }
+
+ fun onNavigateBack() {
+ viewModelScope.launch {
+ popBackStack()
+ }
+ }
+
+ private fun buildConstraintOrNull(): ConstraintData.NotificationPosted? {
+ if (value.isEmpty()) {
+ return null
+ }
+
+ return when (selectedField) {
+ NotificationField.PACKAGE ->
+ ConstraintData.NotificationPosted.FromApp(
+ packageName = value,
+ appName = selectedAppName!!,
+ )
+
+ NotificationField.TITLE -> ConstraintData.NotificationPosted.Title(
+ text = value,
+ matchMode = matchMode,
+ )
+
+ NotificationField.TEXT -> ConstraintData.NotificationPosted.Text(
+ text = value,
+ matchMode = matchMode,
+ )
+ }
+ }
+}
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 f5d3c0ec57..23aa88983d 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,12 +1,6 @@
package io.github.sds100.keymapper.base.constraints
import io.github.sds100.keymapper.common.utils.Orientation
-import io.github.sds100.keymapper.common.utils.PhysicalOrientation
-import io.github.sds100.keymapper.common.utils.getKey
-import io.github.sds100.keymapper.common.utils.valueOrNull
-import io.github.sds100.keymapper.data.entities.ConstraintEntity
-import io.github.sds100.keymapper.data.entities.EntityExtra
-import io.github.sds100.keymapper.data.entities.getData
import io.github.sds100.keymapper.system.camera.CameraLens
import io.github.sds100.keymapper.system.volume.RingerMode as SystemRingerMode
import java.time.LocalTime
@@ -18,57 +12,33 @@ sealed class ConstraintData {
abstract val id: ConstraintId
@Serializable
- data class AppInForeground(val packageName: String) : ConstraintData() {
+ data class AppInForeground(val packageName: String, val appName: String? = null) :
+ ConstraintData() {
override val id: ConstraintId = ConstraintId.APP_IN_FOREGROUND
}
@Serializable
- data class AppNotInForeground(val packageName: String) : ConstraintData() {
- override val id: ConstraintId = ConstraintId.APP_NOT_IN_FOREGROUND
- }
-
- @Serializable
- data class AppPlayingMedia(val packageName: String) : ConstraintData() {
+ data class AppPlayingMedia(val packageName: String, val appName: String? = null) :
+ ConstraintData() {
override val id: ConstraintId = ConstraintId.APP_PLAYING_MEDIA
}
- @Serializable
- data class AppNotPlayingMedia(val packageName: String) : ConstraintData() {
- override val id: ConstraintId = ConstraintId.APP_NOT_PLAYING_MEDIA
- }
-
@Serializable
data object MediaPlaying : ConstraintData() {
override val id: ConstraintId = ConstraintId.MEDIA_PLAYING
}
- @Serializable
- data object NoMediaPlaying : ConstraintData() {
- override val id: ConstraintId = ConstraintId.MEDIA_NOT_PLAYING
- }
-
@Serializable
data class BtDeviceConnected(val bluetoothAddress: String, val deviceName: String) :
ConstraintData() {
override val id: ConstraintId = ConstraintId.BT_DEVICE_CONNECTED
}
- @Serializable
- data class BtDeviceDisconnected(val bluetoothAddress: String, val deviceName: String) :
- ConstraintData() {
- override val id: ConstraintId = ConstraintId.BT_DEVICE_DISCONNECTED
- }
-
@Serializable
data object ScreenOn : ConstraintData() {
override val id: ConstraintId = ConstraintId.SCREEN_ON
}
- @Serializable
- data object ScreenOff : ConstraintData() {
- override val id: ConstraintId = ConstraintId.SCREEN_OFF
- }
-
@Serializable
data object OrientationPortrait : ConstraintData() {
override val id: ConstraintId = ConstraintId.DISPLAY_ORIENTATION_PORTRAIT
@@ -96,10 +66,13 @@ sealed class ConstraintData {
override val id: ConstraintId = when (physicalOrientation) {
io.github.sds100.keymapper.common.utils.PhysicalOrientation.PORTRAIT ->
ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT
+
io.github.sds100.keymapper.common.utils.PhysicalOrientation.LANDSCAPE ->
ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE
+
io.github.sds100.keymapper.common.utils.PhysicalOrientation.PORTRAIT_INVERTED ->
ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED
+
io.github.sds100.keymapper.common.utils.PhysicalOrientation.LANDSCAPE_INVERTED ->
ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED
}
@@ -115,71 +88,36 @@ sealed class ConstraintData {
override val id: ConstraintId = ConstraintId.FLASHLIGHT_ON
}
- @Serializable
- data class FlashlightOff(val lens: CameraLens) : ConstraintData() {
- override val id: ConstraintId = ConstraintId.FLASHLIGHT_OFF
- }
-
@Serializable
data object WifiOn : ConstraintData() {
override val id: ConstraintId = ConstraintId.WIFI_ON
}
- @Serializable
- data object WifiOff : ConstraintData() {
- override val id: ConstraintId = ConstraintId.WIFI_OFF
- }
-
@Serializable
data class WifiConnected(val ssid: String?) : ConstraintData() {
override val id: ConstraintId = ConstraintId.WIFI_CONNECTED
}
- @Serializable
- data class WifiDisconnected(val ssid: String?) : ConstraintData() {
- override val id: ConstraintId = ConstraintId.WIFI_DISCONNECTED
- }
-
@Serializable
data class ImeChosen(val imeId: String, val imeLabel: String) : ConstraintData() {
override val id: ConstraintId = ConstraintId.IME_CHOSEN
}
- @Serializable
- data class ImeNotChosen(val imeId: String, val imeLabel: String) : ConstraintData() {
- override val id: ConstraintId = ConstraintId.IME_NOT_CHOSEN
- }
-
@Serializable
data object KeyboardShowing : ConstraintData() {
override val id: ConstraintId = ConstraintId.KEYBOARD_SHOWING
}
- @Serializable
- data object KeyboardNotShowing : ConstraintData() {
- override val id: ConstraintId = ConstraintId.KEYBOARD_NOT_SHOWING
- }
-
@Serializable
data object DeviceIsLocked : ConstraintData() {
override val id: ConstraintId = ConstraintId.DEVICE_IS_LOCKED
}
- @Serializable
- data object DeviceIsUnlocked : ConstraintData() {
- override val id: ConstraintId = ConstraintId.DEVICE_IS_UNLOCKED
- }
-
@Serializable
data object LockScreenShowing : ConstraintData() {
override val id: ConstraintId = ConstraintId.LOCK_SCREEN_SHOWING
}
- @Serializable
- data object LockScreenNotShowing : ConstraintData() {
- override val id: ConstraintId = ConstraintId.LOCK_SCREEN_NOT_SHOWING
- }
-
@Serializable
data object InPhoneCall : ConstraintData() {
override val id: ConstraintId = ConstraintId.IN_PHONE_CALL
@@ -209,11 +147,6 @@ sealed class ConstraintData {
override val id: ConstraintId = ConstraintId.CHARGING
}
- @Serializable
- data object Discharging : ConstraintData() {
- override val id: ConstraintId = ConstraintId.DISCHARGING
- }
-
@Serializable
data object HingeClosed : ConstraintData() {
override val id: ConstraintId = ConstraintId.HINGE_CLOSED
@@ -230,8 +163,30 @@ sealed class ConstraintData {
}
@Serializable
- data object NotificationPanelNotShowing : ConstraintData() {
- override val id: ConstraintId = ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING
+ sealed class NotificationPosted : ConstraintData() {
+ override val id: ConstraintId get() = ConstraintId.NOTIFICATION_POSTED
+
+ /**
+ * A variant that compares free text the user typed, so it can be matched loosely. Matching
+ * is always case insensitive
+ */
+ @Serializable
+ sealed class TextMatch : NotificationPosted() {
+ abstract val text: String
+ abstract val matchMode: TextMatchMode
+ }
+
+ @Serializable
+ data class Title(override val text: String, override val matchMode: TextMatchMode) :
+ TextMatch()
+
+ @Serializable
+ data class Text(override val text: String, override val matchMode: TextMatchMode) :
+ TextMatch()
+
+ @Serializable
+ data class FromApp(val packageName: String, val appName: String? = null) :
+ NotificationPosted()
}
@Serializable
@@ -249,546 +204,13 @@ sealed class ConstraintData {
}
@Serializable
-data class Constraint(val uid: String = UUID.randomUUID().toString(), val data: ConstraintData) {
+data class Constraint(
+ val uid: String = UUID.randomUUID().toString(),
+ val data: ConstraintData,
+ /**
+ * Whether the constraint is inverted so it is satisfied when the [data] is not.
+ */
+ val isNot: Boolean = false,
+) {
val id: ConstraintId get() = data.id
}
-
-object ConstraintModeEntityMapper {
- fun fromEntity(entity: Int): ConstraintMode = when (entity) {
- ConstraintEntity.MODE_AND -> ConstraintMode.AND
- ConstraintEntity.MODE_OR -> ConstraintMode.OR
- else -> throw Exception("don't know how to convert constraint mode entity $entity")
- }
-
- fun toEntity(constraintMode: ConstraintMode): Int = when (constraintMode) {
- ConstraintMode.AND -> ConstraintEntity.MODE_AND
- ConstraintMode.OR -> ConstraintEntity.MODE_OR
- }
-}
-
-object ConstraintEntityMapper {
-
- private val LENS_MAP = mapOf(
- CameraLens.BACK to "option_lens_back",
- CameraLens.FRONT to "option_lens_front",
- )
-
- fun fromEntity(entity: ConstraintEntity): Constraint {
- fun getPackageName(): String =
- entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!!
-
- fun getBluetoothAddress(): String =
- entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!!
-
- fun getBluetoothDeviceName(): String =
- entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!!
-
- fun getCameraLens(): CameraLens {
- val extraValue =
- entity.extras.getData(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS).valueOrNull()!!
- return LENS_MAP.getKey(extraValue)!!
- }
-
- fun getSsid(): String? {
- val extraValue =
- entity.extras.getData(ConstraintEntity.EXTRA_SSID).valueOrNull()
- return extraValue
- }
-
- fun getImeId(): String {
- val extraValue =
- entity.extras.getData(ConstraintEntity.EXTRA_IME_ID).valueOrNull()!!
- return extraValue
- }
-
- fun getImeLabel(): String {
- val extraValue =
- entity.extras.getData(ConstraintEntity.EXTRA_IME_LABEL).valueOrNull()!!
- return extraValue
- }
-
- fun getResolutionWidth(): Int =
- entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_WIDTH).valueOrNull()!!.toInt()
-
- fun getResolutionHeight(): Int =
- entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_HEIGHT).valueOrNull()!!.toInt()
-
- val constraintData = when (entity.type) {
- ConstraintEntity.APP_FOREGROUND -> ConstraintData.AppInForeground(
- getPackageName(),
- )
-
- ConstraintEntity.APP_NOT_FOREGROUND -> ConstraintData.AppNotInForeground(
- getPackageName(),
- )
-
- ConstraintEntity.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia(
- getPackageName(),
- )
-
- ConstraintEntity.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia(
- getPackageName(),
- )
-
- ConstraintEntity.MEDIA_PLAYING -> ConstraintData.MediaPlaying
- ConstraintEntity.NO_MEDIA_PLAYING -> ConstraintData.NoMediaPlaying
-
- ConstraintEntity.BT_DEVICE_CONNECTED ->
- ConstraintData.BtDeviceConnected(
- getBluetoothAddress(),
- getBluetoothDeviceName(),
- )
-
- ConstraintEntity.BT_DEVICE_DISCONNECTED ->
- ConstraintData.BtDeviceDisconnected(
- getBluetoothAddress(),
- getBluetoothDeviceName(),
- )
-
- ConstraintEntity.ORIENTATION_0 -> ConstraintData.OrientationCustom(
- Orientation.ORIENTATION_0,
- )
-
- ConstraintEntity.ORIENTATION_90 -> ConstraintData.OrientationCustom(
- Orientation.ORIENTATION_90,
- )
-
- ConstraintEntity.ORIENTATION_180 -> ConstraintData.OrientationCustom(
- Orientation.ORIENTATION_180,
- )
-
- ConstraintEntity.ORIENTATION_270 -> ConstraintData.OrientationCustom(
- Orientation.ORIENTATION_270,
- )
-
- ConstraintEntity.ORIENTATION_PORTRAIT -> ConstraintData.OrientationPortrait
- ConstraintEntity.ORIENTATION_LANDSCAPE -> ConstraintData.OrientationLandscape
-
- ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT ->
- ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT)
- ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE ->
- ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE)
- ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED ->
- ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT_INVERTED)
- ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED ->
- ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE_INVERTED)
-
- ConstraintEntity.DISPLAY_RESOLUTION -> ConstraintData.DisplayResolution(
- width = getResolutionWidth(),
- height = getResolutionHeight(),
- )
-
- ConstraintEntity.SCREEN_OFF -> ConstraintData.ScreenOff
- ConstraintEntity.SCREEN_ON -> ConstraintData.ScreenOn
-
- ConstraintEntity.FLASHLIGHT_ON -> ConstraintData.FlashlightOn(
- getCameraLens(),
- )
-
- ConstraintEntity.FLASHLIGHT_OFF -> ConstraintData.FlashlightOff(
- getCameraLens(),
- )
-
- ConstraintEntity.WIFI_ON -> ConstraintData.WifiOn
- ConstraintEntity.WIFI_OFF -> ConstraintData.WifiOff
- ConstraintEntity.WIFI_CONNECTED -> ConstraintData.WifiConnected(getSsid())
- ConstraintEntity.WIFI_DISCONNECTED -> ConstraintData.WifiDisconnected(
- getSsid(),
- )
-
- ConstraintEntity.IME_CHOSEN -> ConstraintData.ImeChosen(
- getImeId(),
- getImeLabel(),
- )
-
- ConstraintEntity.IME_NOT_CHOSEN -> ConstraintData.ImeNotChosen(
- getImeId(),
- getImeLabel(),
- )
-
- ConstraintEntity.KEYBOARD_SHOWING -> ConstraintData.KeyboardShowing
- ConstraintEntity.KEYBOARD_NOT_SHOWING -> ConstraintData.KeyboardNotShowing
-
- ConstraintEntity.DEVICE_IS_UNLOCKED -> ConstraintData.DeviceIsUnlocked
- ConstraintEntity.DEVICE_IS_LOCKED -> ConstraintData.DeviceIsLocked
- ConstraintEntity.LOCK_SCREEN_SHOWING -> ConstraintData.LockScreenShowing
- ConstraintEntity.LOCK_SCREEN_NOT_SHOWING -> ConstraintData.LockScreenNotShowing
-
- ConstraintEntity.PHONE_RINGING -> ConstraintData.PhoneRinging
- ConstraintEntity.IN_PHONE_CALL -> ConstraintData.InPhoneCall
- ConstraintEntity.NOT_IN_PHONE_CALL -> ConstraintData.NotInPhoneCall
-
- ConstraintEntity.RINGER_MODE_NORMAL ->
- ConstraintData.RingerMode(SystemRingerMode.NORMAL)
- ConstraintEntity.RINGER_MODE_VIBRATE ->
- ConstraintData.RingerMode(SystemRingerMode.VIBRATE)
- ConstraintEntity.RINGER_MODE_SILENT ->
- ConstraintData.RingerMode(SystemRingerMode.SILENT)
-
- ConstraintEntity.CHARGING -> ConstraintData.Charging
- ConstraintEntity.DISCHARGING -> ConstraintData.Discharging
-
- ConstraintEntity.HINGE_CLOSED -> ConstraintData.HingeClosed
- ConstraintEntity.HINGE_OPEN -> ConstraintData.HingeOpen
-
- ConstraintEntity.NOTIFICATION_PANEL_SHOWING -> ConstraintData.NotificationPanelShowing
- ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING ->
- ConstraintData.NotificationPanelNotShowing
-
- ConstraintEntity.TIME -> {
- val startTime =
- entity.extras.getData(ConstraintEntity.EXTRA_START_TIME).valueOrNull()!!
- .split(":")
- val startHour = startTime[0].toInt()
- val startMin = startTime[1].toInt()
-
- val endTime =
- entity.extras.getData(ConstraintEntity.EXTRA_END_TIME).valueOrNull()!!
- .split(":")
- val endHour = endTime[0].toInt()
- val endMin = endTime[1].toInt()
-
- ConstraintData.Time(
- startHour = startHour,
- startMinute = startMin,
- endHour = endHour,
- endMinute = endMin,
- )
- }
-
- else -> throw Exception(
- "don't know how to convert constraint entity with type ${entity.type}",
- )
- }
-
- return Constraint(
- uid = entity.uid,
- data = constraintData,
- )
- }
-
- fun toEntity(constraint: Constraint): ConstraintEntity = when (constraint.data) {
- is ConstraintData.AppInForeground -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.APP_FOREGROUND,
- extras = listOf(
- EntityExtra(
- ConstraintEntity.EXTRA_PACKAGE_NAME,
- constraint.data.packageName,
- ),
- ),
- )
-
- is ConstraintData.AppNotInForeground -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.APP_NOT_FOREGROUND,
- extras = listOf(
- EntityExtra(
- ConstraintEntity.EXTRA_PACKAGE_NAME,
- constraint.data.packageName,
- ),
- ),
- )
-
- is ConstraintData.AppPlayingMedia -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.APP_PLAYING_MEDIA,
- extras = listOf(
- EntityExtra(
- ConstraintEntity.EXTRA_PACKAGE_NAME,
- constraint.data.packageName,
- ),
- ),
- )
-
- is ConstraintData.AppNotPlayingMedia -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.APP_NOT_PLAYING_MEDIA,
- extras = listOf(
- EntityExtra(
- ConstraintEntity.EXTRA_PACKAGE_NAME,
- constraint.data.packageName,
- ),
- ),
- )
-
- is ConstraintData.MediaPlaying -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.MEDIA_PLAYING,
- )
-
- is ConstraintData.NoMediaPlaying -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.NO_MEDIA_PLAYING,
- )
-
- is ConstraintData.BtDeviceConnected -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.BT_DEVICE_CONNECTED,
- extras = listOf(
- EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.data.bluetoothAddress),
- EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName),
- ),
- )
-
- is ConstraintData.BtDeviceDisconnected -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.BT_DEVICE_DISCONNECTED,
- extras = listOf(
- EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.data.bluetoothAddress),
- EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName),
- ),
- )
-
- is ConstraintData.OrientationCustom -> when (constraint.data.orientation) {
- Orientation.ORIENTATION_0 -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.ORIENTATION_0,
- )
-
- Orientation.ORIENTATION_90 -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.ORIENTATION_90,
- )
-
- Orientation.ORIENTATION_180 -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.ORIENTATION_180,
- )
-
- Orientation.ORIENTATION_270 -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.ORIENTATION_270,
- )
- }
-
- is ConstraintData.OrientationLandscape -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.ORIENTATION_LANDSCAPE,
- )
-
- is ConstraintData.OrientationPortrait -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.ORIENTATION_PORTRAIT,
- )
-
- is ConstraintData.PhysicalOrientation -> when (constraint.data.physicalOrientation) {
- PhysicalOrientation.PORTRAIT -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT,
- )
- PhysicalOrientation.LANDSCAPE -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE,
- )
- PhysicalOrientation.PORTRAIT_INVERTED -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED,
- )
- PhysicalOrientation.LANDSCAPE_INVERTED -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED,
- )
- }
-
- is ConstraintData.DisplayResolution -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.DISPLAY_RESOLUTION,
- EntityExtra(
- ConstraintEntity.EXTRA_RESOLUTION_WIDTH,
- constraint.data.width.toString(),
- ),
- EntityExtra(
- ConstraintEntity.EXTRA_RESOLUTION_HEIGHT,
- constraint.data.height.toString(),
- ),
- )
-
- is ConstraintData.ScreenOff -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.SCREEN_OFF,
- )
-
- is ConstraintData.ScreenOn -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.SCREEN_ON,
- )
-
- is ConstraintData.FlashlightOff -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.FLASHLIGHT_OFF,
- EntityExtra(
- ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS,
- LENS_MAP[constraint.data.lens]!!,
- ),
- )
-
- is ConstraintData.FlashlightOn -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.FLASHLIGHT_ON,
- EntityExtra(
- ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS,
- LENS_MAP[constraint.data.lens]!!,
- ),
- )
-
- is ConstraintData.WifiConnected -> {
- val extras = mutableListOf()
-
- if (constraint.data.ssid != null) {
- extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid))
- }
-
- ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.WIFI_CONNECTED,
- extras = extras,
- )
- }
-
- is ConstraintData.WifiDisconnected -> {
- val extras = mutableListOf()
-
- if (constraint.data.ssid != null) {
- extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid))
- }
-
- ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.WIFI_DISCONNECTED,
- extras = extras,
- )
- }
-
- is ConstraintData.WifiOff -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.WIFI_OFF,
- )
-
- is ConstraintData.WifiOn -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.WIFI_ON,
- )
-
- is ConstraintData.ImeChosen -> {
- ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.IME_CHOSEN,
- EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId),
- EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel),
- )
- }
-
- is ConstraintData.ImeNotChosen -> {
- ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.IME_NOT_CHOSEN,
- EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId),
- EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel),
- )
- }
-
- is ConstraintData.KeyboardShowing -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.KEYBOARD_SHOWING,
- )
-
- is ConstraintData.KeyboardNotShowing -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.KEYBOARD_NOT_SHOWING,
- )
-
- is ConstraintData.DeviceIsLocked -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.DEVICE_IS_LOCKED,
- )
-
- is ConstraintData.DeviceIsUnlocked -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.DEVICE_IS_UNLOCKED,
- )
-
- is ConstraintData.LockScreenShowing -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.LOCK_SCREEN_SHOWING,
- )
-
- is ConstraintData.LockScreenNotShowing -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.LOCK_SCREEN_NOT_SHOWING,
- )
-
- is ConstraintData.InPhoneCall -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.IN_PHONE_CALL,
- )
-
- is ConstraintData.NotInPhoneCall -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.NOT_IN_PHONE_CALL,
- )
-
- is ConstraintData.PhoneRinging -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.PHONE_RINGING,
- )
-
- is ConstraintData.RingerMode -> when (constraint.data.ringerMode) {
- SystemRingerMode.NORMAL -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.RINGER_MODE_NORMAL,
- )
- SystemRingerMode.VIBRATE -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.RINGER_MODE_VIBRATE,
- )
- SystemRingerMode.SILENT -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.RINGER_MODE_SILENT,
- )
- }
-
- is ConstraintData.Charging -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.CHARGING,
- )
-
- is ConstraintData.Discharging -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.DISCHARGING,
- )
-
- is ConstraintData.HingeClosed -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.HINGE_CLOSED,
- )
-
- is ConstraintData.HingeOpen -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.HINGE_OPEN,
- )
-
- is ConstraintData.NotificationPanelShowing -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.NOTIFICATION_PANEL_SHOWING,
- )
-
- is ConstraintData.NotificationPanelNotShowing -> ConstraintEntity(
- uid = constraint.uid,
- ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING,
- )
-
- is ConstraintData.Time -> ConstraintEntity(
- uid = constraint.uid,
- type = ConstraintEntity.TIME,
- EntityExtra(
- ConstraintEntity.EXTRA_START_TIME,
- "${constraint.data.startHour}:${constraint.data.startMinute}",
- ),
- EntityExtra(
- ConstraintEntity.EXTRA_END_TIME,
- "${constraint.data.endHour}:${constraint.data.endMinute}",
- ),
- )
- }
-}
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 e4937d1529..a41bbb6f88 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
@@ -12,5 +12,6 @@ enum class ConstraintCategory {
PHONE,
POWER,
DEVICE,
+ NOTIFICATIONS,
TIME,
}
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 ac326d39b4..bf2e7a88f8 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
@@ -20,5 +20,6 @@ enum class ConstraintDependency {
CHARGING_STATE,
HINGE_STATE,
NOTIFICATION_PANEL_STATE,
+ POSTED_NOTIFICATIONS,
DISPLAY_RESOLUTIONS,
}
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
new file mode 100644
index 0000000000..857de3fd2e
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt
@@ -0,0 +1,550 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.common.utils.Orientation
+import io.github.sds100.keymapper.common.utils.PhysicalOrientation
+import io.github.sds100.keymapper.common.utils.getKey
+import io.github.sds100.keymapper.common.utils.valueOrNull
+import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.EntityExtra
+import io.github.sds100.keymapper.data.entities.getData
+import io.github.sds100.keymapper.system.camera.CameraLens
+import io.github.sds100.keymapper.system.volume.RingerMode
+
+object ConstraintEntityMapper {
+
+ private val LENS_MAP = mapOf(
+ CameraLens.BACK to "option_lens_back",
+ CameraLens.FRONT to "option_lens_front",
+ )
+
+ /**
+ * These entity types were replaced by the positive constraint with the NOT extra.
+ */
+ @Suppress("DEPRECATION")
+ private val NOT_ENTITY_TYPES = setOf(
+ ConstraintEntity.APP_NOT_FOREGROUND,
+ ConstraintEntity.APP_NOT_PLAYING_MEDIA,
+ ConstraintEntity.NO_MEDIA_PLAYING,
+ ConstraintEntity.BT_DEVICE_DISCONNECTED,
+ ConstraintEntity.SCREEN_OFF,
+ ConstraintEntity.FLASHLIGHT_OFF,
+ ConstraintEntity.WIFI_OFF,
+ ConstraintEntity.WIFI_DISCONNECTED,
+ ConstraintEntity.IME_NOT_CHOSEN,
+ ConstraintEntity.KEYBOARD_NOT_SHOWING,
+ ConstraintEntity.DEVICE_IS_UNLOCKED,
+ ConstraintEntity.LOCK_SCREEN_NOT_SHOWING,
+ ConstraintEntity.DISCHARGING,
+ ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING,
+ )
+
+ @Suppress("DEPRECATION")
+ fun fromEntity(entity: ConstraintEntity): Constraint {
+ fun getPackageName(): String =
+ entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!!
+
+ fun getAppName(): String? =
+ entity.extras.getData(ConstraintEntity.EXTRA_APP_NAME).valueOrNull()
+
+ fun getBluetoothAddress(): String =
+ entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!!
+
+ fun getBluetoothDeviceName(): String =
+ entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!!
+
+ fun getCameraLens(): CameraLens {
+ val extraValue =
+ entity.extras.getData(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS).valueOrNull()!!
+ return LENS_MAP.getKey(extraValue)!!
+ }
+
+ fun getSsid(): String? {
+ val extraValue =
+ entity.extras.getData(ConstraintEntity.EXTRA_SSID).valueOrNull()
+ return extraValue
+ }
+
+ fun getImeId(): String {
+ val extraValue =
+ entity.extras.getData(ConstraintEntity.EXTRA_IME_ID).valueOrNull()!!
+ return extraValue
+ }
+
+ fun getImeLabel(): String {
+ val extraValue =
+ entity.extras.getData(ConstraintEntity.EXTRA_IME_LABEL).valueOrNull()!!
+ return extraValue
+ }
+
+ fun getResolutionWidth(): Int =
+ entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_WIDTH).valueOrNull()!!.toInt()
+
+ fun getResolutionHeight(): Int =
+ entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_HEIGHT).valueOrNull()!!.toInt()
+
+ val constraintData = when (entity.type) {
+ ConstraintEntity.APP_FOREGROUND,
+ ConstraintEntity.APP_NOT_FOREGROUND,
+ -> ConstraintData.AppInForeground(getPackageName(), getAppName())
+
+ ConstraintEntity.APP_PLAYING_MEDIA,
+ ConstraintEntity.APP_NOT_PLAYING_MEDIA,
+ -> ConstraintData.AppPlayingMedia(getPackageName(), getAppName())
+
+ ConstraintEntity.MEDIA_PLAYING,
+ ConstraintEntity.NO_MEDIA_PLAYING,
+ -> ConstraintData.MediaPlaying
+
+ ConstraintEntity.BT_DEVICE_CONNECTED,
+ ConstraintEntity.BT_DEVICE_DISCONNECTED,
+ -> ConstraintData.BtDeviceConnected(
+ getBluetoothAddress(),
+ getBluetoothDeviceName(),
+ )
+
+ ConstraintEntity.ORIENTATION_0 -> ConstraintData.OrientationCustom(
+ Orientation.ORIENTATION_0,
+ )
+
+ ConstraintEntity.ORIENTATION_90 -> ConstraintData.OrientationCustom(
+ Orientation.ORIENTATION_90,
+ )
+
+ ConstraintEntity.ORIENTATION_180 -> ConstraintData.OrientationCustom(
+ Orientation.ORIENTATION_180,
+ )
+
+ ConstraintEntity.ORIENTATION_270 -> ConstraintData.OrientationCustom(
+ Orientation.ORIENTATION_270,
+ )
+
+ ConstraintEntity.ORIENTATION_PORTRAIT -> ConstraintData.OrientationPortrait
+
+ ConstraintEntity.ORIENTATION_LANDSCAPE -> ConstraintData.OrientationLandscape
+
+ ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT ->
+ ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT)
+
+ ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE ->
+ ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE)
+
+ ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED ->
+ ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT_INVERTED)
+
+ ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED ->
+ ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE_INVERTED)
+
+ ConstraintEntity.DISPLAY_RESOLUTION -> ConstraintData.DisplayResolution(
+ width = getResolutionWidth(),
+ height = getResolutionHeight(),
+ )
+
+ ConstraintEntity.SCREEN_ON,
+ ConstraintEntity.SCREEN_OFF,
+ -> ConstraintData.ScreenOn
+
+ ConstraintEntity.FLASHLIGHT_ON,
+ ConstraintEntity.FLASHLIGHT_OFF,
+ -> ConstraintData.FlashlightOn(getCameraLens())
+
+ ConstraintEntity.WIFI_ON,
+ ConstraintEntity.WIFI_OFF,
+ -> ConstraintData.WifiOn
+
+ ConstraintEntity.WIFI_CONNECTED,
+ ConstraintEntity.WIFI_DISCONNECTED,
+ -> ConstraintData.WifiConnected(getSsid())
+
+ ConstraintEntity.IME_CHOSEN,
+ ConstraintEntity.IME_NOT_CHOSEN,
+ -> ConstraintData.ImeChosen(
+ getImeId(),
+ getImeLabel(),
+ )
+
+ ConstraintEntity.KEYBOARD_SHOWING,
+ ConstraintEntity.KEYBOARD_NOT_SHOWING,
+ -> ConstraintData.KeyboardShowing
+
+ ConstraintEntity.DEVICE_IS_LOCKED,
+ ConstraintEntity.DEVICE_IS_UNLOCKED,
+ -> ConstraintData.DeviceIsLocked
+
+ ConstraintEntity.LOCK_SCREEN_SHOWING,
+ ConstraintEntity.LOCK_SCREEN_NOT_SHOWING,
+ -> ConstraintData.LockScreenShowing
+
+ ConstraintEntity.PHONE_RINGING -> ConstraintData.PhoneRinging
+
+ ConstraintEntity.IN_PHONE_CALL -> ConstraintData.InPhoneCall
+
+ ConstraintEntity.NOT_IN_PHONE_CALL -> ConstraintData.NotInPhoneCall
+
+ ConstraintEntity.RINGER_MODE_NORMAL ->
+ ConstraintData.RingerMode(RingerMode.NORMAL)
+
+ ConstraintEntity.RINGER_MODE_VIBRATE ->
+ ConstraintData.RingerMode(RingerMode.VIBRATE)
+
+ ConstraintEntity.RINGER_MODE_SILENT ->
+ ConstraintData.RingerMode(RingerMode.SILENT)
+
+ ConstraintEntity.CHARGING,
+ ConstraintEntity.DISCHARGING,
+ -> ConstraintData.Charging
+
+ ConstraintEntity.HINGE_CLOSED -> ConstraintData.HingeClosed
+
+ ConstraintEntity.HINGE_OPEN -> ConstraintData.HingeOpen
+
+ ConstraintEntity.NOTIFICATION_PANEL_SHOWING,
+ ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING,
+ -> ConstraintData.NotificationPanelShowing
+
+ ConstraintEntity.NOTIFICATION_POSTED -> {
+ val value = entity.extras
+ .getData(ConstraintEntity.EXTRA_NOTIFICATION_VALUE).valueOrNull()!!
+
+ when (
+ NotificationField.valueOf(
+ entity.extras
+ .getData(ConstraintEntity.EXTRA_NOTIFICATION_FIELD).valueOrNull()!!,
+ )
+ ) {
+ NotificationField.PACKAGE ->
+ ConstraintData.NotificationPosted.FromApp(
+ packageName = value,
+ appName = getAppName(),
+ )
+
+ NotificationField.TITLE -> ConstraintData.NotificationPosted.Title(
+ text = value,
+ matchMode = entity.getNotificationMatchMode(),
+ )
+
+ NotificationField.TEXT -> ConstraintData.NotificationPosted.Text(
+ text = value,
+ matchMode = entity.getNotificationMatchMode(),
+ )
+ }
+ }
+
+ ConstraintEntity.TIME -> {
+ val startTime =
+ entity.extras.getData(ConstraintEntity.EXTRA_START_TIME).valueOrNull()!!
+ .split(":")
+ val startHour = startTime[0].toInt()
+ val startMin = startTime[1].toInt()
+
+ val endTime =
+ entity.extras.getData(ConstraintEntity.EXTRA_END_TIME).valueOrNull()!!
+ .split(":")
+ val endHour = endTime[0].toInt()
+ val endMin = endTime[1].toInt()
+
+ ConstraintData.Time(
+ startHour = startHour,
+ startMinute = startMin,
+ endHour = endHour,
+ endMinute = endMin,
+ )
+ }
+
+ else -> throw Exception(
+ "don't know how to convert constraint entity with type ${entity.type}",
+ )
+ }
+
+ val isNotType = entity.type in NOT_ENTITY_TYPES
+
+ return Constraint(
+ uid = entity.uid,
+ data = constraintData,
+ isNot = isNotType != entity.isNot,
+ )
+ }
+
+ fun toEntity(constraint: Constraint): ConstraintEntity =
+ toEntityWithoutNot(constraint).copy(isNot = constraint.isNot)
+
+ private fun toEntityWithoutNot(constraint: Constraint): ConstraintEntity =
+ when (constraint.data) {
+ is ConstraintData.AppInForeground -> ConstraintEntity(
+ uid = constraint.uid,
+ type = ConstraintEntity.APP_FOREGROUND,
+ extras = listOfNotNull(
+ EntityExtra(
+ ConstraintEntity.EXTRA_PACKAGE_NAME,
+ constraint.data.packageName,
+ ),
+ constraint.data.appName?.let {
+ EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it)
+ },
+ ),
+ )
+
+ is ConstraintData.AppPlayingMedia -> ConstraintEntity(
+ uid = constraint.uid,
+ type = ConstraintEntity.APP_PLAYING_MEDIA,
+ extras = listOfNotNull(
+ EntityExtra(
+ ConstraintEntity.EXTRA_PACKAGE_NAME,
+ constraint.data.packageName,
+ ),
+ constraint.data.appName?.let {
+ EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it)
+ },
+ ),
+ )
+
+ is ConstraintData.MediaPlaying -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.MEDIA_PLAYING,
+ )
+
+ is ConstraintData.BtDeviceConnected -> ConstraintEntity(
+ uid = constraint.uid,
+ type = ConstraintEntity.BT_DEVICE_CONNECTED,
+ extras = listOf(
+ EntityExtra(
+ ConstraintEntity.EXTRA_BT_ADDRESS,
+ constraint.data.bluetoothAddress,
+ ),
+ EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName),
+ ),
+ )
+
+ is ConstraintData.OrientationCustom -> when (constraint.data.orientation) {
+ Orientation.ORIENTATION_0 -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.ORIENTATION_0,
+ )
+
+ Orientation.ORIENTATION_90 -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.ORIENTATION_90,
+ )
+
+ Orientation.ORIENTATION_180 -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.ORIENTATION_180,
+ )
+
+ Orientation.ORIENTATION_270 -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.ORIENTATION_270,
+ )
+ }
+
+ is ConstraintData.OrientationLandscape -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.ORIENTATION_LANDSCAPE,
+ )
+
+ is ConstraintData.OrientationPortrait -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.ORIENTATION_PORTRAIT,
+ )
+
+ is ConstraintData.PhysicalOrientation -> when (constraint.data.physicalOrientation) {
+ PhysicalOrientation.PORTRAIT -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT,
+ )
+
+ PhysicalOrientation.LANDSCAPE -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE,
+ )
+
+ PhysicalOrientation.PORTRAIT_INVERTED -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED,
+ )
+
+ PhysicalOrientation.LANDSCAPE_INVERTED -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED,
+ )
+ }
+
+ is ConstraintData.DisplayResolution -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.DISPLAY_RESOLUTION,
+ EntityExtra(
+ ConstraintEntity.EXTRA_RESOLUTION_WIDTH,
+ constraint.data.width.toString(),
+ ),
+ EntityExtra(
+ ConstraintEntity.EXTRA_RESOLUTION_HEIGHT,
+ constraint.data.height.toString(),
+ ),
+ )
+
+ is ConstraintData.ScreenOn -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.SCREEN_ON,
+ )
+
+ is ConstraintData.FlashlightOn -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.FLASHLIGHT_ON,
+ EntityExtra(
+ ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS,
+ LENS_MAP[constraint.data.lens]!!,
+ ),
+ )
+
+ is ConstraintData.WifiConnected -> {
+ val extras = mutableListOf()
+
+ if (constraint.data.ssid != null) {
+ extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid))
+ }
+
+ ConstraintEntity(
+ uid = constraint.uid,
+ type = ConstraintEntity.WIFI_CONNECTED,
+ extras = extras,
+ )
+ }
+
+ is ConstraintData.WifiOn -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.WIFI_ON,
+ )
+
+ is ConstraintData.ImeChosen -> {
+ ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.IME_CHOSEN,
+ EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId),
+ EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel),
+ )
+ }
+
+ is ConstraintData.KeyboardShowing -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.KEYBOARD_SHOWING,
+ )
+
+ is ConstraintData.DeviceIsLocked -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.DEVICE_IS_LOCKED,
+ )
+
+ is ConstraintData.LockScreenShowing -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.LOCK_SCREEN_SHOWING,
+ )
+
+ is ConstraintData.InPhoneCall -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.IN_PHONE_CALL,
+ )
+
+ is ConstraintData.NotInPhoneCall -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.NOT_IN_PHONE_CALL,
+ )
+
+ is ConstraintData.PhoneRinging -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.PHONE_RINGING,
+ )
+
+ is ConstraintData.RingerMode -> when (constraint.data.ringerMode) {
+ RingerMode.NORMAL -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.RINGER_MODE_NORMAL,
+ )
+
+ RingerMode.VIBRATE -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.RINGER_MODE_VIBRATE,
+ )
+
+ RingerMode.SILENT -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.RINGER_MODE_SILENT,
+ )
+ }
+
+ is ConstraintData.Charging -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.CHARGING,
+ )
+
+ is ConstraintData.HingeClosed -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.HINGE_CLOSED,
+ )
+
+ is ConstraintData.HingeOpen -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.HINGE_OPEN,
+ )
+
+ is ConstraintData.NotificationPanelShowing -> ConstraintEntity(
+ uid = constraint.uid,
+ ConstraintEntity.NOTIFICATION_PANEL_SHOWING,
+ )
+
+ is ConstraintData.NotificationPosted -> {
+ val value = when (constraint.data) {
+ is ConstraintData.NotificationPosted.FromApp -> constraint.data.packageName
+ is ConstraintData.NotificationPosted.TextMatch -> constraint.data.text
+ }
+
+ val textMatch = constraint.data as? ConstraintData.NotificationPosted.TextMatch
+
+ ConstraintEntity(
+ uid = constraint.uid,
+ type = ConstraintEntity.NOTIFICATION_POSTED,
+ extras = buildList {
+ add(
+ EntityExtra(
+ ConstraintEntity.EXTRA_NOTIFICATION_FIELD,
+ NotificationField.of(constraint.data).name,
+ ),
+ )
+ add(EntityExtra(ConstraintEntity.EXTRA_NOTIFICATION_VALUE, value))
+
+ // Only free text constraints have a match mode, so writing one for an app
+ // would store an option that can never be edited.
+ if (textMatch != null) {
+ add(
+ EntityExtra(
+ ConstraintEntity.EXTRA_NOTIFICATION_MATCH_MODE,
+ textMatch.matchMode.name,
+ ),
+ )
+ }
+
+ if (constraint.data is ConstraintData.NotificationPosted.FromApp) {
+ constraint.data.appName?.let {
+ add(EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it))
+ }
+ }
+ },
+ )
+ }
+
+ is ConstraintData.Time -> ConstraintEntity(
+ uid = constraint.uid,
+ type = ConstraintEntity.TIME,
+ EntityExtra(
+ ConstraintEntity.EXTRA_START_TIME,
+ "${constraint.data.startHour}:${constraint.data.startMinute}",
+ ),
+ EntityExtra(
+ ConstraintEntity.EXTRA_END_TIME,
+ "${constraint.data.endHour}:${constraint.data.endMinute}",
+ ),
+ )
+ }
+
+ private fun ConstraintEntity.getNotificationMatchMode(): TextMatchMode = TextMatchMode.valueOf(
+ extras.getData(ConstraintEntity.EXTRA_NOTIFICATION_MATCH_MODE).valueOrNull()!!,
+ )
+}
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 67eecff0af..3e6b70f7a3 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,7 +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 io.github.sds100.keymapper.common.utils.onSuccess
import io.github.sds100.keymapper.system.SystemError
@@ -37,34 +36,32 @@ class LazyConstraintErrorSnapshot(
override fun getError(constraint: Constraint): KMError? {
when (constraint.data) {
- is ConstraintData.AppInForeground -> return getAppError(constraint.data.packageName)
- is ConstraintData.AppNotInForeground -> return getAppError(constraint.data.packageName)
+ is ConstraintData.AppInForeground ->
+ return getAppError(
+ constraint.data.packageName,
+ constraint.data.appName,
+ constraint.isNot,
+ )
is ConstraintData.AppPlayingMedia -> {
if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) {
return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER)
}
- return getAppError(constraint.data.packageName)
+ return getAppError(
+ constraint.data.packageName,
+ constraint.data.appName,
+ constraint.isNot,
+ )
}
- is ConstraintData.AppNotPlayingMedia -> {
+ ConstraintData.MediaPlaying -> {
if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) {
return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER)
}
-
- return getAppError(constraint.data.packageName)
}
- ConstraintData.MediaPlaying, ConstraintData.NoMediaPlaying -> {
- if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) {
- return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER)
- }
- }
-
- is ConstraintData.BtDeviceConnected,
- is ConstraintData.BtDeviceDisconnected,
- -> {
+ is ConstraintData.BtDeviceConnected -> {
if (!systemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) {
return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH)
}
@@ -83,23 +80,6 @@ class LazyConstraintErrorSnapshot(
}
is ConstraintData.FlashlightOn -> {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M)
- }
-
- if (!flashLenses.contains(constraint.data.lens)) {
- return when (constraint.data.lens) {
- CameraLens.FRONT -> KMError.FrontFlashNotFound
- CameraLens.BACK -> KMError.BackFlashNotFound
- }
- }
- }
-
- is ConstraintData.FlashlightOff -> {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M)
- }
-
if (!flashLenses.contains(constraint.data.lens)) {
return when (constraint.data.lens) {
CameraLens.FRONT -> KMError.FrontFlashNotFound
@@ -108,7 +88,7 @@ class LazyConstraintErrorSnapshot(
}
}
- is ConstraintData.WifiConnected, is ConstraintData.WifiDisconnected -> {
+ is ConstraintData.WifiConnected -> {
if (!isPermissionGranted(Permission.ACCESS_FINE_LOCATION)) {
return SystemError.PermissionDenied(Permission.ACCESS_FINE_LOCATION)
}
@@ -129,21 +109,42 @@ class LazyConstraintErrorSnapshot(
}
}
+ is ConstraintData.NotificationPosted -> {
+ if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) {
+ return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER)
+ }
+
+ if (constraint.data is ConstraintData.NotificationPosted.FromApp) {
+ return getAppError(
+ constraint.data.packageName,
+ constraint.data.appName,
+ constraint.isNot,
+ )
+ }
+ }
+
else -> Unit
}
return null
}
- private fun getAppError(packageName: String): KMError? {
+ private fun getAppError(packageName: String, appName: String?, isNot: Boolean): KMError? {
+ // If the constraint is negated then a missing or disabled app can never satisfy the
+ // un-negated condition, so the negated condition is trivially true. There is nothing
+ // to warn about.
+ if (isNot) {
+ return null
+ }
+
packageManager.isAppEnabled(packageName).onSuccess { isEnabled ->
if (!isEnabled) {
- return KMError.AppDisabled(packageName)
+ return KMError.AppDisabled(packageName, appName)
}
}
if (!packageManager.isAppInstalled(packageName)) {
- return KMError.AppNotFound(packageName)
+ return KMError.AppNotFound(packageName, appName)
}
return null
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt
new file mode 100644
index 0000000000..74122e5951
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt
@@ -0,0 +1,15 @@
+package io.github.sds100.keymapper.base.constraints
+
+import java.util.UUID
+import kotlinx.serialization.Serializable
+
+/**
+ * @param mode how the constraints in this group are combined.
+ */
+@Serializable
+data class ConstraintGroup(
+ val uid: String = UUID.randomUUID().toString(),
+ val name: String? = null,
+ val constraints: List = emptyList(),
+ val mode: ConstraintMode = ConstraintMode.AND,
+)
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
new file mode 100644
index 0000000000..2c72ecc45a
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt
@@ -0,0 +1,474 @@
+package io.github.sds100.keymapper.base.constraints
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandIn
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.horizontalScroll
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.FlashlightOn
+import androidx.compose.material.icons.outlined.Wifi
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+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.runtime.key
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+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 io.github.sds100.keymapper.base.R
+import io.github.sds100.keymapper.base.compose.KeyMapperTheme
+import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
+import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState
+import io.github.sds100.keymapper.base.utils.ui.compose.EXPAND_ANIMATION_DURATION
+import io.github.sds100.keymapper.base.utils.ui.compose.ExpandableDraggableCard
+import io.github.sds100.keymapper.base.utils.ui.compose.horizontalFadingEdges
+
+/**
+ * The description and error fade out in the first half of the animation so they are invisible
+ * before the shrink clips them. When expanding, the space is made first and the text fades in
+ * during the second half.
+ */
+private val summaryEnterTransition: EnterTransition =
+ expandVertically(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.Top,
+ ) + fadeIn(
+ tween(
+ durationMillis = EXPAND_ANIMATION_DURATION / 2,
+ delayMillis = EXPAND_ANIMATION_DURATION / 2,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+
+private val summaryExitTransition: ExitTransition =
+ fadeOut(tween(EXPAND_ANIMATION_DURATION / 2, easing = FastOutSlowInEasing)) +
+ shrinkVertically(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ shrinkTowards = Alignment.Top,
+ )
+
+/**
+ * Animate both width and height so the title row does not jump when the rename button appears.
+ */
+private val renameButtonEnterTransition: EnterTransition =
+ fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) +
+ expandIn(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.CenterStart,
+ )
+
+private val renameButtonExitTransition: ExitTransition =
+ fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) +
+ shrinkOut(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ shrinkTowards = Alignment.CenterStart,
+ )
+
+@Composable
+fun ConstraintGroupItem(
+ modifier: Modifier = Modifier,
+ model: ConstraintGroupListItemModel,
+ isExpanded: Boolean,
+ isDraggingEnabled: Boolean = false,
+ isDragging: Boolean = false,
+ isReorderingEnabled: Boolean = false,
+ dragDropState: DragDropState? = null,
+ onExpandedChange: (Boolean) -> Unit = {},
+ onSelectMode: (ConstraintMode) -> Unit = {},
+ onAddConstraintClick: () -> Unit = {},
+ onRenameClick: () -> Unit = {},
+ onDeleteClick: () -> Unit = {},
+ onRemoveConstraintClick: (String) -> Unit = {},
+ onFixConstraintClick: (String) -> Unit = {},
+ onNotClick: (String) -> Unit = {},
+ onMoveUp: (() -> Unit)? = null,
+ onMoveDown: (() -> Unit)? = null,
+) {
+ val countBasedTitle = when {
+ model.constraints.size == 1 -> stringResource(R.string.constraint_group_title_single)
+
+ model.mode == ConstraintMode.AND -> pluralStringResource(
+ R.plurals.constraint_group_title_and,
+ model.constraints.size,
+ model.constraints.size,
+ )
+
+ else -> pluralStringResource(
+ R.plurals.constraint_group_title_or,
+ model.constraints.size,
+ model.constraints.size,
+ )
+ }
+
+ val title = model.name ?: countBasedTitle
+
+ ExpandableDraggableCard(
+ modifier = modifier,
+ key = model.uid,
+ isExpanded = isExpanded,
+ isDragging = isDragging,
+ isReorderingEnabled = isReorderingEnabled,
+ isDraggingEnabled = isDraggingEnabled,
+ dragDropState = dragDropState,
+ dragHandleContentDescription = stringResource(R.string.drag_handle_for, title),
+ expandContentDescription = stringResource(R.string.constraint_group_expand),
+ collapseContentDescription = stringResource(R.string.constraint_group_collapse),
+ onExpandedChange = onExpandedChange,
+ onMoveUp = onMoveUp,
+ onMoveDown = onMoveDown,
+ headerContent = { expanded ->
+ HeaderText(
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = 8.dp),
+ title = if (isExpanded) {
+ model.name ?: countBasedTitle
+ } else {
+ model.name
+ ?: model.description
+ },
+ model = model,
+ isExpanded = expanded,
+ onRenameClick = onRenameClick,
+ )
+ },
+ expandedContent = {
+ ExpandedContent(
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 8.dp,
+ bottom = 8.dp,
+ top = 8.dp,
+ ),
+ model = model,
+ onSelectMode = onSelectMode,
+ onAddConstraintClick = onAddConstraintClick,
+ onDeleteClick = onDeleteClick,
+ onRemoveConstraintClick = onRemoveConstraintClick,
+ onFixConstraintClick = onFixConstraintClick,
+ onNotClick = onNotClick,
+ )
+ },
+ )
+}
+
+@Composable
+private fun HeaderText(
+ modifier: Modifier = Modifier,
+ title: String,
+ model: ConstraintGroupListItemModel,
+ isExpanded: Boolean,
+ onRenameClick: () -> Unit,
+) {
+ Column(modifier = modifier) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ modifier = Modifier
+ .weight(1f, fill = false).animateContentSize(),
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = renameButtonEnterTransition,
+ exit = renameButtonExitTransition,
+ ) {
+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 16.dp,
+ ) {
+ IconButton(onClick = onRenameClick) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = Icons.Rounded.Edit,
+ contentDescription = stringResource(R.string.action_list_item_rename),
+ )
+ }
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = !isExpanded,
+ enter = summaryEnterTransition,
+ exit = summaryExitTransition,
+ ) {
+ if (model.error != null) {
+ Text(
+ text = model.error,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExpandedContent(
+ modifier: Modifier = Modifier,
+ model: ConstraintGroupListItemModel,
+ onSelectMode: (ConstraintMode) -> Unit,
+ onAddConstraintClick: () -> Unit,
+ onDeleteClick: () -> Unit,
+ onRemoveConstraintClick: (String) -> Unit,
+ onFixConstraintClick: (String) -> Unit,
+ onNotClick: (String) -> Unit,
+) {
+ Column(modifier) {
+ Text(
+ modifier = Modifier.padding(end = 8.dp),
+ text = when {
+ model.constraints.size == 1 ->
+ stringResource(R.string.constraint_group_explanation_single)
+
+ model.mode == ConstraintMode.AND ->
+ stringResource(R.string.constraint_group_explanation_and)
+
+ else -> stringResource(R.string.constraint_group_explanation_or)
+ },
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Spacer(Modifier.size(8.dp))
+
+ GroupConstraintList(
+ modifier = Modifier.padding(end = 8.dp),
+ constraints = model.constraints,
+ mode = model.mode,
+ onRemoveConstraintClick = onRemoveConstraintClick,
+ onFixConstraintClick = onFixConstraintClick,
+ onNotClick = onNotClick,
+ )
+
+ Spacer(Modifier.size(8.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ConstraintModeButtons(
+ modifier = Modifier.width(160.dp),
+ mode = model.mode,
+ onSelectMode = onSelectMode,
+ isEnabled = model.constraints.size > 1,
+ )
+
+ Spacer(Modifier.weight(1f))
+
+ val scrollState = rememberScrollState()
+
+ // Scroll to the end initially so the delete button is always visible without
+ // needing to scroll.
+ LaunchedEffect(Unit) {
+ scrollState.scrollTo(scrollState.maxValue)
+ }
+
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .horizontalFadingEdges(scrollState)
+ .horizontalScroll(scrollState),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 16.dp,
+ ) {
+ IconButton(onClick = onAddConstraintClick) {
+ Icon(
+ imageVector = Icons.Rounded.Add,
+ contentDescription = stringResource(
+ R.string.constraint_group_add_constraint,
+ ),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ IconButton(onClick = onDeleteClick) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.constraint_group_delete),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun GroupConstraintList(
+ modifier: Modifier = Modifier,
+ constraints: List,
+ mode: ConstraintMode,
+ onRemoveConstraintClick: (String) -> Unit,
+ onFixConstraintClick: (String) -> Unit,
+ onNotClick: (String) -> Unit,
+) {
+ val linkText = when (mode) {
+ ConstraintMode.AND -> stringResource(R.string.constraint_mode_and)
+ ConstraintMode.OR -> stringResource(R.string.constraint_mode_or)
+ }
+
+ Column(modifier = modifier) {
+ constraints.forEachIndexed { index, constraint ->
+ key(constraint.id) {
+ ConstraintListItem(
+ modifier = Modifier.fillMaxWidth(),
+ model = constraint,
+ onRemoveClick = { onRemoveConstraintClick(constraint.id) },
+ onFixClick = { onFixConstraintClick(constraint.id) },
+ onNotClick = { onNotClick(constraint.id) },
+ )
+
+ if (index < constraints.lastIndex) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ text = linkText,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+}
+
+private val previewConstraints = listOf(
+ ConstraintListItemModel(
+ id = "1",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn),
+ text = "Flashlight is on",
+ isNot = true,
+ error = "Flashlight not found",
+ isErrorFixable = true,
+ ),
+ ConstraintListItemModel(
+ id = "2",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.Wifi),
+ text = "Wi-Fi is on",
+ ),
+)
+
+@PreviewLightDark
+@Preview(widthDp = 300)
+@Composable
+private fun AndExpandedPreview() {
+ KeyMapperTheme {
+ ConstraintGroupItem(
+ model = ConstraintGroupListItemModel(
+ uid = "group",
+ mode = ConstraintMode.AND,
+ constraints = previewConstraints,
+ description = "Flashlight is not on AND Wi-Fi is on",
+ ),
+ isExpanded = true,
+ isReorderingEnabled = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun OrExpandedPreview() {
+ KeyMapperTheme {
+ ConstraintGroupItem(
+ model = ConstraintGroupListItemModel(
+ uid = "group",
+ mode = ConstraintMode.OR,
+ constraints = previewConstraints.map { it.copy(error = null, isNot = false) },
+ description = "Flashlight is on OR Wi-Fi is on",
+ ),
+ isExpanded = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SingleExpandedPreview() {
+ KeyMapperTheme {
+ ConstraintGroupItem(
+ model = ConstraintGroupListItemModel(
+ uid = "group",
+ mode = ConstraintMode.AND,
+ constraints = listOf(previewConstraints[1]),
+ description = "Wi-Fi is on",
+ ),
+ isExpanded = true,
+ )
+ }
+}
+
+@PreviewLightDark
+@Preview(widthDp = 300)
+@Composable
+private fun CollapsedPreview() {
+ KeyMapperTheme {
+ ConstraintGroupItem(
+ model = ConstraintGroupListItemModel(
+ uid = "group",
+ mode = ConstraintMode.AND,
+ constraints = previewConstraints,
+ description = "Flashlight is not on AND Wi-Fi is on",
+ ),
+ isExpanded = false,
+ isReorderingEnabled = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun CollapsedErrorPreview() {
+ KeyMapperTheme {
+ ConstraintGroupItem(
+ model = ConstraintGroupListItemModel(
+ uid = "group",
+ mode = ConstraintMode.AND,
+ constraints = previewConstraints,
+ description = "Flashlight is not on AND Wi-Fi is on",
+ ),
+ isExpanded = false,
+ isReorderingEnabled = true,
+ )
+ }
+}
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 944eed883d..d3b2014c7a 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
@@ -7,17 +7,12 @@ import kotlinx.serialization.Serializable
@Serializable
enum class ConstraintId {
APP_IN_FOREGROUND,
- APP_NOT_IN_FOREGROUND,
APP_PLAYING_MEDIA,
- APP_NOT_PLAYING_MEDIA,
- MEDIA_NOT_PLAYING,
MEDIA_PLAYING,
BT_DEVICE_CONNECTED,
- BT_DEVICE_DISCONNECTED,
SCREEN_ON,
- SCREEN_OFF,
DISPLAY_ORIENTATION_PORTRAIT,
DISPLAY_ORIENTATION_LANDSCAPE,
@@ -34,23 +29,16 @@ enum class ConstraintId {
DISPLAY_RESOLUTION,
FLASHLIGHT_ON,
- FLASHLIGHT_OFF,
WIFI_ON,
- WIFI_OFF,
WIFI_CONNECTED,
- WIFI_DISCONNECTED,
IME_CHOSEN,
- IME_NOT_CHOSEN,
KEYBOARD_SHOWING,
- KEYBOARD_NOT_SHOWING,
DEVICE_IS_LOCKED,
- DEVICE_IS_UNLOCKED,
LOCK_SCREEN_SHOWING,
- LOCK_SCREEN_NOT_SHOWING,
IN_PHONE_CALL,
NOT_IN_PHONE_CALL,
@@ -61,13 +49,12 @@ enum class ConstraintId {
RINGER_MODE_SILENT,
CHARGING,
- DISCHARGING,
HINGE_CLOSED,
HINGE_OPEN,
NOTIFICATION_PANEL_SHOWING,
- NOTIFICATION_PANEL_NOT_SHOWING,
+ NOTIFICATION_POSTED,
TIME,
}
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 ab1115f82b..0af2d36e3e 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,25 +1,26 @@
package io.github.sds100.keymapper.base.constraints
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+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.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
-import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -30,11 +31,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+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
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.drawable
@@ -42,168 +52,262 @@ import io.github.sds100.keymapper.base.utils.ui.drawable
fun ConstraintListItem(
modifier: Modifier = Modifier,
model: ConstraintListItemModel,
+ isDragging: Boolean = false,
onRemoveClick: () -> Unit = {},
onFixClick: () -> Unit = {},
+ onNotClick: () -> Unit = {},
) {
- Column(modifier = modifier.fillMaxWidth()) {
- ElevatedCard(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = 48.dp)
- .height(IntrinsicSize.Min)
- .padding(start = 16.dp, end = 16.dp),
- colors = CardDefaults.elevatedCardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- ),
- ) {
- Row(
- modifier = Modifier.fillMaxSize(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Spacer(Modifier.width(8.dp))
-
- if (model.error == null) {
- when (model.icon) {
- is ComposeIconInfo.Vector -> Icon(
- modifier = Modifier.size(24.dp),
- imageVector = model.icon.imageVector,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- )
-
- is ComposeIconInfo.Drawable -> {
- val painter = rememberDrawablePainter(model.icon.drawable)
- Icon(
- modifier = Modifier.size(24.dp),
- painter = painter,
- contentDescription = null,
- tint = Color.Unspecified,
- )
- }
- }
- }
-
- val primaryText = model.text
-
- Spacer(Modifier.width(8.dp))
+ ElevatedCard(
+ modifier = modifier,
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ elevation = if (isDragging) {
+ CardDefaults.elevatedCardElevation(defaultElevation = 6.dp)
+ } else {
+ CardDefaults.elevatedCardElevation()
+ },
+ ) {
+ val listItemModifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 48.dp)
+ .padding(vertical = 4.dp)
- TextColumn(
- modifier = Modifier
- .weight(1f)
- .padding(vertical = 8.dp),
- primaryText = primaryText,
- errorText = model.error,
- )
+ BoxWithConstraints {
+ when {
+ maxWidth < 400.dp -> {
+ ConstraintListItemMedium(
+ modifier = listItemModifier,
+ model = model,
+ onFixClick = onFixClick,
+ onNotClick = onNotClick,
+ onRemoveClick = onRemoveClick,
+ )
+ }
- CompositionLocalProvider(
- LocalMinimumInteractiveComponentSize provides 16.dp,
- ) {
- if (model.error != null && model.isErrorFixable) {
- FilledTonalButton(
- modifier = Modifier.padding(start = 8.dp, end = 8.dp),
- onClick = onFixClick,
- colors = ButtonDefaults.filledTonalButtonColors(
- containerColor = MaterialTheme.colorScheme.error,
- contentColor = MaterialTheme.colorScheme.onError,
- ),
- ) {
- Text(
- text = stringResource(R.string.button_fix),
- )
- }
- }
-
- IconButton(onClick = onRemoveClick) {
- Icon(
- imageVector = Icons.Rounded.Clear,
- contentDescription = stringResource(
- R.string.constraint_list_item_remove,
- ),
- tint = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp),
- )
- }
+ else -> {
+ ConstraintListItemLarge(
+ modifier = listItemModifier,
+ model = model,
+ onFixClick = onFixClick,
+ onNotClick = onNotClick,
+ onRemoveClick = onRemoveClick,
+ )
}
}
}
+ }
+}
- if (model.constraintModeLink == null) {
- // Important! Show an empty spacer so the height of the card remains constant
- // while dragging. If the height changes while dragging it can lead to janky
- // behavior.
- Spacer(Modifier.height(32.dp))
- } else {
- Spacer(Modifier.height(4.dp))
+@Composable
+private fun ConstraintListItemLarge(
+ modifier: Modifier = Modifier,
+ model: ConstraintListItemModel,
+ onFixClick: () -> Unit,
+ onNotClick: () -> Unit,
+ onRemoveClick: () -> Unit,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(Modifier.width(8.dp))
- val text = when (model.constraintModeLink) {
- ConstraintMode.AND -> stringResource(R.string.constraint_mode_and)
- ConstraintMode.OR -> stringResource(R.string.constraint_mode_or)
+ ConstraintIcon(icon = model.icon)
+
+ Spacer(Modifier.width(8.dp))
+
+ Text(
+ modifier = Modifier.weight(1f),
+ 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))
}
+ }
- Text(
- modifier = Modifier.align(Alignment.CenterHorizontally),
- text = text,
- )
+ Spacer(Modifier.width(8.dp))
+
+ NotToggle(isNot = model.isNot, onClick = onNotClick)
- Spacer(Modifier.height(4.dp))
+ 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),
+ )
+ }
}
}
}
@Composable
-private fun TextColumn(
+private fun ConstraintListItemMedium(
modifier: Modifier = Modifier,
- primaryText: String,
- errorText: String? = null,
+ model: ConstraintListItemModel,
+ onFixClick: () -> Unit,
+ onNotClick: () -> Unit,
+ onRemoveClick: () -> Unit,
) {
- Column(
+ Row(
modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
) {
+ Spacer(Modifier.width(8.dp))
+
+ ConstraintIcon(icon = model.icon)
+
+ Spacer(Modifier.width(8.dp))
+
Text(
- text = primaryText,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ text = model.text,
+ style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
- if (errorText != null) {
- Text(
- text = errorText,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
+
+ 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),
+ )
+ }
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ NotToggle(isNot = model.isNot, onClick = onNotClick)
+
+ IconButton(onClick = onRemoveClick) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = Icons.Rounded.Clear,
+ contentDescription = stringResource(R.string.constraint_list_item_remove),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NotToggle(modifier: Modifier = Modifier, isNot: Boolean, onClick: () -> Unit) {
+ val contentDescription = stringResource(R.string.constraint_list_item_not)
+
+ val modifier = modifier
+ .toggleable(value = isNot, role = Role.Checkbox, onValueChange = { onClick() })
+ .semantics { this.contentDescription = contentDescription }
+
+ if (isNot) {
+ CompactFilledTonalButton(
+ modifier = modifier,
+ onClick = onClick,
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.inversePrimary,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ ) {
+ Text(text = stringResource(R.string.constraint_not), fontWeight = FontWeight.Black)
+ }
+ } else {
+ CompactOutlinedButton(
+ modifier = modifier,
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.38f),
+ ),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.inversePrimary),
+ onClick = onClick,
+ ) {
+ Text(text = stringResource(R.string.constraint_not))
+ }
+ }
+}
+
+@Composable
+private fun ConstraintIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) {
+ when (icon) {
+ is ComposeIconInfo.Vector -> Icon(
+ modifier = modifier.size(24.dp),
+ imageVector = icon.imageVector,
+ contentDescription = null,
+ )
+
+ is ComposeIconInfo.Drawable -> {
+ val painter = rememberDrawablePainter(icon.drawable)
+ Icon(
+ modifier = modifier.size(24.dp),
+ painter = painter,
+ contentDescription = null,
+ tint = Color.Unspecified,
)
}
}
}
-@Preview
+@PreviewLightDark
+@Preview(widthDp = 200)
@Composable
private fun VectorPreview() {
- ConstraintListItem(
- model = ConstraintListItemModel(
- id = "id",
- icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
- constraintModeLink = ConstraintMode.AND,
- text = "Clear all",
- error = null,
- isErrorFixable = true,
- ),
- )
+ KeyMapperTheme {
+ ConstraintListItem(
+ model = ConstraintListItemModel(
+ id = "id",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll),
+ text = "Clear all",
+ ),
+ )
+ }
}
-@Preview
+@PreviewLightDark
+@Preview(widthDp = 200)
+@Composable
+private fun NotErrorPreview() {
+ KeyMapperTheme {
+ ConstraintListItem(
+ model = ConstraintListItemModel(
+ id = "id",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn),
+ text = "Flashlight is on",
+ isNot = true,
+ error = "Permission need to control camera",
+ isErrorFixable = true,
+ ),
+ )
+ }
+}
+
+@PreviewLightDark
@Composable
private fun DrawablePreview() {
val drawable = LocalContext.current.drawable(R.mipmap.ic_launcher_round)
- ConstraintListItem(
- model = ConstraintListItemModel(
- id = "id",
- text = "Dismiss most recent notification",
- error = null,
- isErrorFixable = true,
- icon = ComposeIconInfo.Drawable(drawable),
- constraintModeLink = ConstraintMode.OR,
- ),
- )
+ KeyMapperTheme {
+ ConstraintListItem(
+ model = ConstraintListItemModel(
+ id = "id",
+ text = "Key Mapper is in foreground",
+ icon = ComposeIconInfo.Drawable(drawable),
+ ),
+ )
+ }
}
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 39f2ca17a2..0285a23587 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
@@ -5,11 +5,19 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
data class ConstraintListItemModel(
val id: String,
val icon: ComposeIconInfo,
- /**
- * Null if no link should be shown between the items.
- */
- val constraintModeLink: ConstraintMode?,
val text: String,
+ val isNot: Boolean = false,
val error: String? = null,
val isErrorFixable: Boolean = true,
)
+
+data class ConstraintGroupListItemModel(
+ val uid: String,
+ val name: String? = null,
+ val mode: ConstraintMode,
+ val constraints: List,
+ val description: String,
+ val isExpanded: Boolean = false,
+) {
+ val error: String? = constraints.firstNotNullOfOrNull { it.error }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt
new file mode 100644
index 0000000000..821f4898d3
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt
@@ -0,0 +1,74 @@
+package io.github.sds100.keymapper.base.constraints
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.SegmentedButtonColors
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+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.utils.ui.compose.KeyMapperSegmentedButtonRow
+
+/**
+ * The AND/OR switcher that is used for the constraints in a group and for combining the groups.
+ */
+@Composable
+fun ConstraintModeButtons(
+ mode: ConstraintMode,
+ onSelectMode: (ConstraintMode) -> Unit,
+ modifier: Modifier = Modifier,
+ isEnabled: Boolean = true,
+ colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(),
+) {
+ KeyMapperSegmentedButtonRow(
+ modifier = modifier,
+ buttonStates = listOf(
+ ConstraintMode.AND to stringResource(R.string.constraint_mode_and),
+ ConstraintMode.OR to stringResource(R.string.constraint_mode_or),
+ ),
+ selectedState = mode,
+ onStateSelected = onSelectMode,
+ isCompact = true,
+ isEnabled = isEnabled,
+ colors = colors,
+ )
+}
+
+@PreviewLightDark
+@Composable
+private fun Preview() {
+ KeyMapperTheme {
+ Surface {
+ ConstraintModeButtons(
+ modifier = Modifier
+ .padding(16.dp)
+ .width(160.dp),
+ mode = ConstraintMode.AND,
+ onSelectMode = {},
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun DisabledPreview() {
+ KeyMapperTheme {
+ Surface {
+ ConstraintModeButtons(
+ modifier = Modifier
+ .padding(16.dp)
+ .width(160.dp),
+ mode = ConstraintMode.OR,
+ onSelectMode = {},
+ isEnabled = false,
+ )
+ }
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt
new file mode 100644
index 0000000000..19ec539f35
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt
@@ -0,0 +1,16 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.data.entities.ConstraintEntity
+
+object ConstraintModeEntityMapper {
+ fun fromEntity(entity: Int): ConstraintMode = when (entity) {
+ ConstraintEntity.MODE_AND -> ConstraintMode.AND
+ ConstraintEntity.MODE_OR -> ConstraintMode.OR
+ else -> throw Exception("don't know how to convert constraint mode entity $entity")
+ }
+
+ fun toEntity(constraintMode: ConstraintMode): Int = when (constraintMode) {
+ ConstraintMode.AND -> ConstraintEntity.MODE_AND
+ ConstraintMode.OR -> ConstraintEntity.MODE_OR
+ }
+}
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 0cc28dc291..1fd23b1d7c 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
@@ -19,6 +19,8 @@ import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter
import io.github.sds100.keymapper.system.lock.LockScreenAdapter
import io.github.sds100.keymapper.system.media.MediaAdapter
import io.github.sds100.keymapper.system.network.NetworkAdapter
+import io.github.sds100.keymapper.system.notifications.NotificationAdapter
+import io.github.sds100.keymapper.system.notifications.PostedNotification
import io.github.sds100.keymapper.system.phone.CallState
import io.github.sds100.keymapper.system.phone.PhoneAdapter
import io.github.sds100.keymapper.system.power.PowerAdapter
@@ -42,6 +44,7 @@ class LazyConstraintSnapshot(
powerAdapter: PowerAdapter,
private val foldableAdapter: FoldableAdapter,
volumeAdapter: VolumeAdapter,
+ notificationAdapter: NotificationAdapter,
) : ConstraintSnapshot {
private val appInForeground: String? by lazy { accessibilityService.rootNode?.packageName }
private val connectedBluetoothDevices: Set by lazy {
@@ -69,7 +72,7 @@ class LazyConstraintSnapshot(
private val isKeyboardShowing: Boolean by lazy {
accessibilityService.isInputMethodVisible.firstBlocking()
}
- private val callState: CallState by lazy { phoneAdapter.getCallState() }
+ private val callState: CallState? by lazy { phoneAdapter.getCallState() }
private val isCharging: Boolean by lazy { powerAdapter.isCharging.value }
private val ringerMode: RingerMode by lazy { volumeAdapter.ringerMode }
@@ -85,6 +88,10 @@ class LazyConstraintSnapshot(
accessibilityService.isNotificationShadeExpanded.firstBlocking()
}
+ private val activeNotifications: List by lazy {
+ notificationAdapter.activeNotifications.value
+ }
+
private val localTime = LocalTime.now()
private fun isMediaPlaying(): Boolean {
@@ -95,34 +102,19 @@ class LazyConstraintSnapshot(
override fun isSatisfied(constraint: Constraint): Boolean {
val isSatisfied = when (constraint.data) {
is ConstraintData.AppInForeground -> appInForeground == constraint.data.packageName
- is ConstraintData.AppNotInForeground -> appInForeground != constraint.data.packageName
- is ConstraintData.AppPlayingMedia -> {
- if (appsPlayingMedia.contains(constraint.data.packageName)) {
- return true
- } else if (appInForeground == constraint.data.packageName && isMediaPlaying()) {
- return true
- } else {
- return false
- }
- }
- is ConstraintData.AppNotPlayingMedia ->
- appsPlayingMedia.none { it == constraint.data.packageName } &&
- !(appInForeground == constraint.data.packageName && isMediaPlaying())
+ is ConstraintData.AppPlayingMedia ->
+ appsPlayingMedia.contains(constraint.data.packageName) ||
+ (appInForeground == constraint.data.packageName && isMediaPlaying())
is ConstraintData.MediaPlaying -> isMediaPlaying()
- is ConstraintData.NoMediaPlaying -> !isMediaPlaying()
-
is ConstraintData.BtDeviceConnected -> {
connectedBluetoothDevices.any { it.address == constraint.data.bluetoothAddress }
}
- is ConstraintData.BtDeviceDisconnected -> {
- connectedBluetoothDevices.none { it.address == constraint.data.bluetoothAddress }
- }
-
is ConstraintData.OrientationCustom -> orientation == constraint.data.orientation
+
is ConstraintData.OrientationLandscape ->
orientation == Orientation.ORIENTATION_90 ||
orientation == Orientation.ORIENTATION_270
@@ -134,7 +126,6 @@ class LazyConstraintSnapshot(
is ConstraintData.PhysicalOrientation ->
physicalOrientation == constraint.data.physicalOrientation
- is ConstraintData.ScreenOff -> !isScreenOn
is ConstraintData.ScreenOn -> isScreenOn
// Compare the resolution regardless of orientation so that the constraint
@@ -149,8 +140,8 @@ class LazyConstraintSnapshot(
displaySize.height == constraint.data.width
)
- is ConstraintData.FlashlightOff -> !cameraAdapter.isFlashlightOn(constraint.data.lens)
is ConstraintData.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.data.lens)
+
is ConstraintData.WifiConnected -> {
if (constraint.data.ssid == null) {
// connected to any network
@@ -160,22 +151,14 @@ class LazyConstraintSnapshot(
}
}
- is ConstraintData.WifiDisconnected ->
- if (constraint.data.ssid == null) {
- // connected to no network
- connectedWifiSSID == null
- } else {
- connectedWifiSSID != constraint.data.ssid
- }
-
- is ConstraintData.WifiOff -> !isWifiEnabled
is ConstraintData.WifiOn -> isWifiEnabled
+
is ConstraintData.ImeChosen -> chosenImeId == constraint.data.imeId
- is ConstraintData.ImeNotChosen -> chosenImeId != constraint.data.imeId
+
is ConstraintData.KeyboardShowing -> isKeyboardShowing
- is ConstraintData.KeyboardNotShowing -> !isKeyboardShowing
+
is ConstraintData.DeviceIsLocked -> isLocked
- is ConstraintData.DeviceIsUnlocked -> !isLocked
+
is ConstraintData.InPhoneCall ->
callState == CallState.IN_PHONE_CALL ||
audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL)
@@ -191,7 +174,6 @@ class LazyConstraintSnapshot(
is ConstraintData.RingerMode -> ringerMode == constraint.data.ringerMode
is ConstraintData.Charging -> isCharging
- is ConstraintData.Discharging -> !isCharging
is ConstraintData.HingeClosed -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -220,12 +202,22 @@ class LazyConstraintSnapshot(
is ConstraintData.LockScreenShowing ->
isLockscreenShowing &&
appInForeground == "com.android.systemui"
- is ConstraintData.LockScreenNotShowing ->
- !isLockscreenShowing ||
- appInForeground != "com.android.systemui"
is ConstraintData.NotificationPanelShowing -> isNotificationShadeExpanded
- is ConstraintData.NotificationPanelNotShowing -> !isNotificationShadeExpanded
+
+ is ConstraintData.NotificationPosted -> if (constraint.isNot) {
+ // Some notifications will not match, such as from the system. The text will
+ // be replaced with a stub saying it is sensitive content.
+
+ // If it is not then none of the notifications can match.
+ return activeNotifications.none {
+ NotificationConstraintMatcher.matches(it, constraint.data)
+ }
+ } else {
+ return activeNotifications.any {
+ NotificationConstraintMatcher.matches(it, constraint.data)
+ }
+ }
is ConstraintData.Time ->
if (constraint.data.startTime.isAfter(constraint.data.endTime)) {
@@ -237,11 +229,14 @@ class LazyConstraintSnapshot(
}
}
- return isSatisfied
+ return isSatisfied != constraint.isNot
}
}
interface ConstraintSnapshot {
+ /**
+ * Whether the constraint is satisfied, taking into account whether it is inverted.
+ */
fun isSatisfied(constraint: Constraint): Boolean
}
@@ -251,36 +246,31 @@ interface ConstraintSnapshot {
*/
fun ConstraintSnapshot.isSatisfied(vararg constraintState: ConstraintState): Boolean {
for (state in constraintState) {
- when (state.mode) {
- ConstraintMode.AND -> {
- for (constraint in state.constraints) {
- if (!isSatisfied(constraint)) {
- return false
- }
- }
- }
+ if (!isStateSatisfied(state)) {
+ return false
+ }
+ }
- ConstraintMode.OR -> {
- // If no constraints then still satisfied
- if (state.constraints.isEmpty()) {
- continue
- }
+ return true
+}
- var anySatisfied = false
+private fun ConstraintSnapshot.isStateSatisfied(state: ConstraintState): Boolean {
+ // Empty groups are ignored and if there are no constraints then it is still satisfied.
+ val groups = state.groups.filter { it.constraints.isNotEmpty() }
- for (constraint in state.constraints) {
- if (isSatisfied(constraint)) {
- anySatisfied = true
- break
- }
- }
+ if (groups.isEmpty()) {
+ return true
+ }
- if (!anySatisfied) {
- return false
- }
- }
- }
+ return when (state.mode) {
+ ConstraintMode.AND -> groups.all { isGroupSatisfied(it) }
+ ConstraintMode.OR -> groups.any { isGroupSatisfied(it) }
}
+}
- return true
+private fun ConstraintSnapshot.isGroupSatisfied(group: ConstraintGroup): Boolean {
+ return when (group.mode) {
+ ConstraintMode.AND -> group.constraints.all { isSatisfied(it) }
+ ConstraintMode.OR -> group.constraints.any { isSatisfied(it) }
+ }
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt
index ad284ccdb2..08538ec3c2 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt
@@ -2,8 +2,14 @@ package io.github.sds100.keymapper.base.constraints
import kotlinx.serialization.Serializable
+/**
+ * @param mode how the results of each group are combined.
+ */
@Serializable
data class ConstraintState(
- val constraints: Set = emptySet(),
+ val groups: List = emptyList(),
val mode: ConstraintMode = ConstraintMode.AND,
-)
+) {
+ val allConstraints: List
+ get() = groups.flatMap { it.constraints }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt
new file mode 100644
index 0000000000..c84e39189f
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt
@@ -0,0 +1,126 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity
+import java.util.UUID
+
+object ConstraintStateEntityMapper {
+
+ /**
+ * For [GroupEntity][io.github.sds100.keymapper.data.entities.GroupEntity], which only ever
+ * stores a single flat list of constraints combined with one mode. Its constraints never
+ * have a [io.github.sds100.keymapper.data.entities.ConstraintEntity.groupUid].
+ */
+ fun fromEntity(entities: List, constraintMode: Int): ConstraintState {
+ val mode = ConstraintModeEntityMapper.fromEntity(constraintMode)
+ val constraints = entities.map { ConstraintEntityMapper.fromEntity(it) }
+
+ val groups = if (constraints.isEmpty()) {
+ emptyList()
+ } else {
+ listOf(
+ ConstraintGroup(
+ uid = UUID.randomUUID().toString(),
+ constraints = constraints,
+ mode = mode,
+ ),
+ )
+ }
+
+ return ConstraintState(groups = groups, mode = mode)
+ }
+
+ /**
+ * @return the constraint entities and the constraint mode to save.
+ */
+ fun toEntity(state: ConstraintState): Pair, Int> {
+ val group = state.groups.singleOrNull()
+ val entities = group?.constraints?.map { ConstraintEntityMapper.toEntity(it) }
+ val mode = group?.mode ?: state.mode
+
+ return Pair(entities.orEmpty(), ConstraintModeEntityMapper.toEntity(mode))
+ }
+
+ /**
+ * For [KeyMapEntity][io.github.sds100.keymapper.data.entities.KeyMapEntity], which supports
+ * multiple nameable constraint groups.
+ *
+ * Constraints that were saved before constraint groups existed do not have a
+ * [ConstraintEntity.groupUid] and the constraint mode is the mode of the only group.
+ *
+ * If there are multiple groups then each constraint entity has a [ConstraintEntity.groupUid]
+ * for the group it is in, and the group's mode/name are looked up from the matching
+ * [io.github.sds100.keymapper.data.entities.ConstraintGroupEntity].
+ */
+ fun fromEntityWithGroups(
+ entities: List,
+ groupEntities: List,
+ constraintMode: Int,
+ ): ConstraintState {
+ val mode = ConstraintModeEntityMapper.fromEntity(constraintMode)
+
+ val hasGroups = entities.any { it.groupUid != null }
+
+ if (!hasGroups) {
+ val constraints = entities.map { ConstraintEntityMapper.fromEntity(it) }
+
+ val groups = if (constraints.isEmpty()) {
+ emptyList()
+ } else {
+ listOf(
+ ConstraintGroup(
+ uid = UUID.randomUUID().toString(),
+ constraints = constraints,
+ mode = mode,
+ ),
+ )
+ }
+
+ return ConstraintState(groups = groups, mode = mode)
+ }
+
+ val groups = entities
+ .groupBy { it.groupUid }
+ .map { (groupUid, groupConstraintEntities) ->
+ val groupEntity = groupEntities.find { it.uid == groupUid }
+ val constraints = groupConstraintEntities.map {
+ ConstraintEntityMapper.fromEntity(it)
+ }
+
+ ConstraintGroup(
+ uid = groupUid ?: constraints.first().uid,
+ name = groupEntity?.name,
+ constraints = constraints,
+ mode = ConstraintModeEntityMapper.fromEntity(
+ groupEntity?.mode ?: ConstraintEntity.DEFAULT_MODE,
+ ),
+ )
+ }
+
+ return ConstraintState(groups = groups, mode = mode)
+ }
+
+ /**
+ * @return the constraint entities, the constraint group entities, and the constraint mode
+ * to save.
+ */
+ fun toEntityWithGroups(
+ state: ConstraintState,
+ ): Triple, List, Int> {
+ val groupEntities = state.groups.map { group ->
+ ConstraintGroupEntity(
+ uid = group.uid,
+ name = group.name,
+ mode = ConstraintModeEntityMapper.toEntity(group.mode),
+ )
+ }
+
+ val entities = state.groups.flatMap { group ->
+ group.constraints.map { constraint ->
+ ConstraintEntityMapper.toEntity(constraint).copy(groupUid = group.uid)
+ }
+ }
+
+ return Triple(entities, groupEntities, ConstraintModeEntityMapper.toEntity(state.mode))
+ }
+}
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 4990777498..f64f6b1cc8 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
@@ -1,5 +1,6 @@
package io.github.sds100.keymapper.base.constraints
+import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import io.github.sds100.keymapper.base.R
@@ -22,38 +23,32 @@ class ConstraintUiHelper(
private val timeFormatter by lazy { TimeUtils.localeDateFormatter(FormatStyle.SHORT) }
- fun getTitle(constraint: Constraint): String = when (constraint.data) {
- is ConstraintData.AppInForeground ->
- getAppName(constraint.data.packageName).handle(
- onSuccess = { getString(R.string.constraint_app_foreground_description, it) },
- onError = { getString(R.string.constraint_choose_app_foreground) },
- )
+ fun getTitle(constraint: Constraint): String {
+ return if (constraint.isNot) {
+ getNotTitle(constraint)
+ } else {
+ getNormalTitle(constraint)
+ }
+ }
- is ConstraintData.AppNotInForeground ->
- getAppName(constraint.data.packageName).handle(
- onSuccess = { getString(R.string.constraint_app_not_foreground_description, it) },
- onError = { getString(R.string.constraint_choose_app_not_foreground) },
- )
+ private fun getNormalTitle(constraint: Constraint): String = when (constraint.data) {
+ is ConstraintData.AppInForeground -> {
+ val name = getAppName(constraint.data.packageName).valueIfFailure {
+ constraint.data.appName ?: constraint.data.packageName
+ }
- is ConstraintData.AppPlayingMedia ->
- getAppName(constraint.data.packageName).handle(
- onSuccess = { getString(R.string.constraint_app_playing_media_description, it) },
- onError = { getString(R.string.constraint_choose_app_playing_media) },
- )
+ getString(R.string.constraint_app_foreground_description, name)
+ }
- is ConstraintData.AppNotPlayingMedia ->
- getAppName(constraint.data.packageName).handle(
- onSuccess = {
- getString(
- R.string.constraint_app_not_playing_media_description,
- it,
- )
- },
- onError = { getString(R.string.constraint_choose_app_playing_media) },
- )
+ is ConstraintData.AppPlayingMedia -> {
+ val name = getAppName(constraint.data.packageName).valueIfFailure {
+ constraint.data.appName ?: constraint.data.packageName
+ }
+
+ getString(R.string.constraint_app_playing_media_description, name)
+ }
is ConstraintData.MediaPlaying -> getString(R.string.constraint_choose_media_playing)
- is ConstraintData.NoMediaPlaying -> getString(R.string.constraint_choose_media_not_playing)
is ConstraintData.BtDeviceConnected ->
getString(
@@ -61,12 +56,6 @@ class ConstraintUiHelper(
constraint.data.deviceName,
)
- is ConstraintData.BtDeviceDisconnected ->
- getString(
- R.string.constraint_bt_device_disconnected_description,
- constraint.data.deviceName,
- )
-
is ConstraintData.OrientationCustom -> {
val resId = when (constraint.data.orientation) {
Orientation.ORIENTATION_0 -> R.string.constraint_choose_orientation_0
@@ -88,10 +77,13 @@ class ConstraintUiHelper(
val resId = when (constraint.data.physicalOrientation) {
PhysicalOrientation.PORTRAIT ->
R.string.constraint_choose_physical_orientation_portrait
+
PhysicalOrientation.LANDSCAPE ->
R.string.constraint_choose_physical_orientation_landscape
+
PhysicalOrientation.PORTRAIT_INVERTED ->
R.string.constraint_choose_physical_orientation_portrait_inverted
+
PhysicalOrientation.LANDSCAPE_INVERTED ->
R.string.constraint_choose_physical_orientation_landscape_inverted
}
@@ -99,9 +91,6 @@ class ConstraintUiHelper(
getString(resId)
}
- is ConstraintData.ScreenOff ->
- getString(R.string.constraint_screen_off_description)
-
is ConstraintData.ScreenOn ->
getString(R.string.constraint_screen_on_description)
@@ -110,12 +99,6 @@ class ConstraintUiHelper(
arrayOf(constraint.data.width, constraint.data.height),
)
- is ConstraintData.FlashlightOff -> if (constraint.data.lens == CameraLens.FRONT) {
- getString(R.string.constraint_front_flashlight_off_description)
- } else {
- getString(R.string.constraint_flashlight_off_description)
- }
-
is ConstraintData.FlashlightOn -> if (constraint.data.lens == CameraLens.FRONT) {
getString(R.string.constraint_front_flashlight_on_description)
} else {
@@ -130,15 +113,6 @@ class ConstraintUiHelper(
}
}
- is ConstraintData.WifiDisconnected -> {
- if (constraint.data.ssid == null) {
- getString(R.string.constraint_wifi_disconnected_any_description)
- } else {
- getString(R.string.constraint_wifi_disconnected_description, constraint.data.ssid)
- }
- }
-
- is ConstraintData.WifiOff -> getString(R.string.constraint_wifi_off)
is ConstraintData.WifiOn -> getString(R.string.constraint_wifi_on)
is ConstraintData.ImeChosen -> {
@@ -149,43 +123,38 @@ class ConstraintUiHelper(
getString(R.string.constraint_ime_chosen_description, label)
}
- is ConstraintData.ImeNotChosen -> {
- val label = getInputMethodLabel(constraint.data.imeId).valueIfFailure {
- constraint.data.imeLabel
- }
-
- getString(R.string.constraint_ime_not_chosen_description, label)
- }
-
is ConstraintData.KeyboardShowing -> getString(
R.string.constraint_keyboard_showing_description,
)
- is ConstraintData.KeyboardNotShowing -> getString(
- R.string.constraint_keyboard_not_showing_description,
- )
is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_locked)
- is ConstraintData.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked)
+
is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call)
+
is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call)
+
is ConstraintData.PhoneRinging -> getString(R.string.constraint_phone_ringing)
+
is ConstraintData.RingerMode -> when (constraint.data.ringerMode) {
RingerMode.NORMAL -> getString(R.string.constraint_ringer_mode_normal)
RingerMode.VIBRATE -> getString(R.string.constraint_ringer_mode_vibrate)
RingerMode.SILENT -> getString(R.string.constraint_ringer_mode_silent)
}
+
is ConstraintData.Charging -> getString(R.string.constraint_charging)
- is ConstraintData.Discharging -> getString(R.string.constraint_discharging)
+
is ConstraintData.HingeClosed -> getString(R.string.constraint_hinge_closed_description)
+
is ConstraintData.HingeOpen -> getString(R.string.constraint_hinge_open_description)
+
is ConstraintData.LockScreenShowing -> getString(R.string.constraint_lock_screen_showing)
- is ConstraintData.LockScreenNotShowing -> getString(
- R.string.constraint_lock_screen_not_showing,
- )
+
is ConstraintData.NotificationPanelShowing ->
getString(R.string.constraint_notification_panel_showing)
- is ConstraintData.NotificationPanelNotShowing ->
- getString(R.string.constraint_notification_panel_not_showing)
+
+ is ConstraintData.NotificationPosted ->
+ getNotificationPostedTitle(constraint.data, isNot = false)
+
is ConstraintData.Time -> getString(
R.string.constraint_time_formatted,
arrayOf(
@@ -195,20 +164,201 @@ class ConstraintUiHelper(
)
}
+ private fun getNotTitle(constraint: Constraint): String = when (constraint.data) {
+ is ConstraintData.AppInForeground -> {
+ val name = getAppName(constraint.data.packageName).valueIfFailure {
+ constraint.data.appName ?: constraint.data.packageName
+ }
+
+ getString(R.string.constraint_app_not_foreground_description, name)
+ }
+
+ is ConstraintData.AppPlayingMedia -> {
+ val name = getAppName(constraint.data.packageName).valueIfFailure {
+ constraint.data.appName ?: constraint.data.packageName
+ }
+
+ getString(R.string.constraint_app_not_playing_media_description, name)
+ }
+
+ is ConstraintData.MediaPlaying ->
+ getString(R.string.constraint_media_playing_not_description)
+
+ is ConstraintData.BtDeviceConnected ->
+ getString(
+ R.string.constraint_bt_device_disconnected_description,
+ constraint.data.deviceName,
+ )
+
+ is ConstraintData.ScreenOn -> getString(R.string.constraint_screen_off_description)
+
+ is ConstraintData.FlashlightOn -> if (constraint.data.lens == CameraLens.FRONT) {
+ getString(R.string.constraint_front_flashlight_off_description)
+ } else {
+ getString(R.string.constraint_flashlight_off_description)
+ }
+
+ is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) {
+ getString(R.string.constraint_wifi_disconnected_any_description)
+ } else {
+ getString(R.string.constraint_wifi_disconnected_description, constraint.data.ssid)
+ }
+
+ is ConstraintData.WifiOn -> getString(R.string.constraint_wifi_off)
+
+ is ConstraintData.ImeChosen -> {
+ val label = getInputMethodLabel(constraint.data.imeId).valueIfFailure {
+ constraint.data.imeLabel
+ }
+
+ getString(R.string.constraint_ime_not_chosen_description, label)
+ }
+
+ is ConstraintData.KeyboardShowing ->
+ getString(R.string.constraint_keyboard_not_showing_description)
+
+ is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_unlocked)
+
+ is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call_not)
+
+ is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call_not)
+
+ is ConstraintData.LockScreenShowing ->
+ getString(R.string.constraint_lock_screen_not_showing)
+
+ is ConstraintData.NotificationPanelShowing ->
+ getString(R.string.constraint_notification_panel_not_showing)
+
+ is ConstraintData.Charging -> getString(R.string.constraint_discharging)
+
+ is ConstraintData.PhoneRinging -> getString(R.string.constraint_phone_not_ringing)
+
+ is ConstraintData.RingerMode -> when (constraint.data.ringerMode) {
+ RingerMode.NORMAL -> getString(R.string.constraint_ringer_mode_not_normal)
+ RingerMode.VIBRATE -> getString(R.string.constraint_ringer_mode_not_vibrate)
+ RingerMode.SILENT -> getString(R.string.constraint_ringer_mode_not_silent)
+ }
+
+ is ConstraintData.HingeClosed ->
+ getString(R.string.constraint_hinge_not_closed_description)
+
+ is ConstraintData.HingeOpen ->
+ getString(R.string.constraint_hinge_not_open_description)
+
+ is ConstraintData.NotificationPosted ->
+ getNotificationPostedTitle(constraint.data, isNot = true)
+
+ is ConstraintData.Time -> getString(
+ R.string.constraint_time_not_formatted,
+ arrayOf(
+ timeFormatter.format(constraint.data.startTime),
+ timeFormatter.format(constraint.data.endTime),
+ ),
+ )
+
+ is ConstraintData.OrientationCustom -> {
+ val resId = when (constraint.data.orientation) {
+ Orientation.ORIENTATION_0 -> R.string.constraint_not_orientation_0
+ Orientation.ORIENTATION_90 -> R.string.constraint_not_orientation_90
+ Orientation.ORIENTATION_180 -> R.string.constraint_not_orientation_180
+ Orientation.ORIENTATION_270 -> R.string.constraint_not_orientation_270
+ }
+
+ getString(resId)
+ }
+
+ is ConstraintData.OrientationLandscape ->
+ getString(R.string.constraint_not_orientation_landscape)
+
+ is ConstraintData.OrientationPortrait ->
+ getString(R.string.constraint_not_orientation_portrait)
+
+ is ConstraintData.PhysicalOrientation -> {
+ val resId = when (constraint.data.physicalOrientation) {
+ PhysicalOrientation.PORTRAIT ->
+ R.string.constraint_not_physical_orientation_portrait
+
+ PhysicalOrientation.LANDSCAPE ->
+ R.string.constraint_not_physical_orientation_landscape
+
+ PhysicalOrientation.PORTRAIT_INVERTED ->
+ R.string.constraint_not_physical_orientation_portrait_inverted
+
+ PhysicalOrientation.LANDSCAPE_INVERTED ->
+ R.string.constraint_not_physical_orientation_landscape_inverted
+ }
+
+ getString(resId)
+ }
+
+ is ConstraintData.DisplayResolution -> getString(
+ R.string.constraint_display_resolution_not_description,
+ arrayOf(constraint.data.width, constraint.data.height),
+ )
+ }
+
fun getIcon(constraint: Constraint): ComposeIconInfo = when (constraint.data) {
is ConstraintData.AppInForeground -> getAppIconInfo(constraint.data.packageName)
?: ComposeIconInfo.Vector(Icons.Rounded.Android)
- is ConstraintData.AppNotInForeground -> getAppIconInfo(constraint.data.packageName)
- ?: ComposeIconInfo.Vector(Icons.Rounded.Android)
-
is ConstraintData.AppPlayingMedia -> getAppIconInfo(constraint.data.packageName)
?: ComposeIconInfo.Vector(Icons.Rounded.Android)
- is ConstraintData.AppNotPlayingMedia -> getAppIconInfo(constraint.data.packageName)
- ?: ComposeIconInfo.Vector(Icons.Rounded.Android)
+ is ConstraintData.NotificationPosted.FromApp ->
+ getAppIconInfo(constraint.data.packageName)
+ ?: ConstraintUtils.getIcon(constraint.id)
+
+ else -> if (constraint.isNot) {
+ ConstraintUtils.getNotIcon(constraint.id)
+ } else {
+ ConstraintUtils.getIcon(constraint.id)
+ }
+ }
+
+ private fun getNotificationPostedTitle(
+ data: ConstraintData.NotificationPosted,
+ isNot: Boolean,
+ ): String = when (data) {
+ is ConstraintData.NotificationPosted.FromApp -> {
+ val appName = getAppName(data.packageName).valueIfFailure {
+ data.appName ?: data.packageName
+ }
+
+ getString(
+ if (isNot) {
+ R.string.constraint_notification_not_posted_from_app
+ } else {
+ R.string.constraint_notification_posted_from_app
+ },
+ appName,
+ )
+ }
+
+ is ConstraintData.NotificationPosted.TextMatch -> getString(
+ if (isNot) {
+ R.string.constraint_notification_not_posted_match
+ } else {
+ R.string.constraint_notification_posted_match
+ },
+ arrayOf(
+ getString(getNotificationFieldNoun(data)),
+ getString(getMatchModeVerb(data.matchMode)),
+ data.text,
+ ),
+ )
+ }
+
+ @StringRes
+ private fun getNotificationFieldNoun(data: ConstraintData.NotificationPosted.TextMatch): Int =
+ when (data) {
+ is ConstraintData.NotificationPosted.Title -> R.string.notification_field_title
+ is ConstraintData.NotificationPosted.Text -> R.string.notification_field_text
+ }
- else -> ConstraintUtils.getIcon(constraint.id)
+ @StringRes
+ private fun getMatchModeVerb(matchMode: TextMatchMode): Int = when (matchMode) {
+ TextMatchMode.CONTAINS -> R.string.notification_match_mode_contains_sentence
+ TextMatchMode.EXACT -> R.string.notification_match_mode_exact_sentence
}
private fun getAppIconInfo(packageName: String): ComposeIconInfo? =
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 cb9d2f2f79..dc42af5605 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
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.MobileOff
import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.NotificationsOff
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.RingVolume
@@ -48,26 +49,20 @@ object ConstraintUtils {
ConstraintCategory.PHONE -> R.string.constraint_cat_phone
ConstraintCategory.POWER -> R.string.constraint_cat_power
ConstraintCategory.DEVICE -> R.string.constraint_cat_device
+ ConstraintCategory.NOTIFICATIONS -> R.string.constraint_cat_notifications
ConstraintCategory.TIME -> R.string.constraint_cat_time
}
fun getCategory(constraintId: ConstraintId): ConstraintCategory = when (constraintId) {
ConstraintId.APP_IN_FOREGROUND,
- ConstraintId.APP_NOT_IN_FOREGROUND,
ConstraintId.APP_PLAYING_MEDIA,
- ConstraintId.APP_NOT_PLAYING_MEDIA,
-> ConstraintCategory.APPS
- ConstraintId.MEDIA_PLAYING,
- ConstraintId.MEDIA_NOT_PLAYING,
- -> ConstraintCategory.MEDIA
+ ConstraintId.MEDIA_PLAYING -> ConstraintCategory.MEDIA
- ConstraintId.BT_DEVICE_CONNECTED,
- ConstraintId.BT_DEVICE_DISCONNECTED,
- -> ConstraintCategory.BLUETOOTH
+ ConstraintId.BT_DEVICE_CONNECTED -> ConstraintCategory.BLUETOOTH
ConstraintId.SCREEN_ON,
- ConstraintId.SCREEN_OFF,
ConstraintId.DISPLAY_ORIENTATION_PORTRAIT,
ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE,
ConstraintId.DISPLAY_ORIENTATION_0,
@@ -81,26 +76,18 @@ object ConstraintUtils {
ConstraintId.DISPLAY_RESOLUTION,
-> ConstraintCategory.DISPLAY
- ConstraintId.FLASHLIGHT_ON,
- ConstraintId.FLASHLIGHT_OFF,
- -> ConstraintCategory.FLASHLIGHT
+ ConstraintId.FLASHLIGHT_ON -> ConstraintCategory.FLASHLIGHT
ConstraintId.WIFI_ON,
- ConstraintId.WIFI_OFF,
ConstraintId.WIFI_CONNECTED,
- ConstraintId.WIFI_DISCONNECTED,
-> ConstraintCategory.WIFI
ConstraintId.IME_CHOSEN,
- ConstraintId.IME_NOT_CHOSEN,
ConstraintId.KEYBOARD_SHOWING,
- ConstraintId.KEYBOARD_NOT_SHOWING,
-> ConstraintCategory.KEYBOARD
ConstraintId.DEVICE_IS_LOCKED,
- ConstraintId.DEVICE_IS_UNLOCKED,
ConstraintId.LOCK_SCREEN_SHOWING,
- ConstraintId.LOCK_SCREEN_NOT_SHOWING,
-> ConstraintCategory.LOCK
ConstraintId.IN_PHONE_CALL,
@@ -111,40 +98,30 @@ object ConstraintUtils {
ConstraintId.RINGER_MODE_SILENT,
-> ConstraintCategory.PHONE
- ConstraintId.CHARGING,
- ConstraintId.DISCHARGING,
- -> ConstraintCategory.POWER
+ ConstraintId.CHARGING -> ConstraintCategory.POWER
ConstraintId.HINGE_CLOSED,
ConstraintId.HINGE_OPEN,
-> ConstraintCategory.DEVICE
ConstraintId.NOTIFICATION_PANEL_SHOWING,
- ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING,
- -> ConstraintCategory.DISPLAY
+ ConstraintId.NOTIFICATION_POSTED,
+ -> ConstraintCategory.NOTIFICATIONS
ConstraintId.TIME -> ConstraintCategory.TIME
}
fun getIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) {
ConstraintId.APP_IN_FOREGROUND,
- ConstraintId.APP_NOT_IN_FOREGROUND,
ConstraintId.APP_PLAYING_MEDIA,
- ConstraintId.APP_NOT_PLAYING_MEDIA,
-> ComposeIconInfo.Vector(Icons.Rounded.Android)
ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.PlayArrow)
- ConstraintId.MEDIA_NOT_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.StopCircle)
-
ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector(
Icons.Outlined.BluetoothConnected,
)
- ConstraintId.BT_DEVICE_DISCONNECTED -> ComposeIconInfo.Vector(
- Icons.Outlined.BluetoothDisabled,
- )
-
ConstraintId.DISPLAY_ORIENTATION_0,
ConstraintId.DISPLAY_ORIENTATION_180,
-> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait)
@@ -169,38 +146,22 @@ object ConstraintUtils {
ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED,
-> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape)
- ConstraintId.SCREEN_OFF -> ComposeIconInfo.Vector(Icons.Outlined.MobileOff)
-
ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait)
ConstraintId.DISPLAY_RESOLUTION -> ComposeIconInfo.Vector(Icons.Outlined.AspectRatio)
- ConstraintId.FLASHLIGHT_OFF -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOff)
-
ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn)
ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.Wifi)
- ConstraintId.WIFI_DISCONNECTED -> ComposeIconInfo.Vector(
- Icons.Outlined.SignalWifiStatusbarNull,
- )
-
- ConstraintId.WIFI_OFF -> ComposeIconInfo.Vector(Icons.Outlined.WifiOff)
-
ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.Wifi)
- ConstraintId.IME_CHOSEN,
- ConstraintId.IME_NOT_CHOSEN,
- -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard)
+ ConstraintId.IME_CHOSEN -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard)
ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard)
- ConstraintId.KEYBOARD_NOT_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.KeyboardHide)
-
ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.Lock)
- ConstraintId.DEVICE_IS_UNLOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen)
-
ConstraintId.IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call)
ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd)
@@ -215,8 +176,6 @@ object ConstraintUtils {
ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.BatteryChargingFull)
- ConstraintId.DISCHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar)
-
ConstraintId.HINGE_CLOSED -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait)
ConstraintId.HINGE_OPEN -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape)
@@ -225,39 +184,67 @@ object ConstraintUtils {
Icons.Outlined.ScreenLockPortrait,
)
- ConstraintId.LOCK_SCREEN_NOT_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen)
-
ConstraintId.NOTIFICATION_PANEL_SHOWING ->
ComposeIconInfo.Vector(Icons.Outlined.Notifications)
- ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING ->
- ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff)
+ ConstraintId.NOTIFICATION_POSTED ->
+ ComposeIconInfo.Vector(Icons.Outlined.NotificationsActive)
ConstraintId.TIME -> ComposeIconInfo.Vector(Icons.Outlined.Timer)
}
- fun getTitleStringId(constraintId: ConstraintId): Int = when (constraintId) {
- ConstraintId.APP_IN_FOREGROUND -> R.string.constraint_choose_app_foreground
+ fun getNotIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) {
+ ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.StopCircle)
- ConstraintId.APP_NOT_IN_FOREGROUND -> R.string.constraint_choose_app_not_foreground
+ ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector(
+ Icons.Outlined.BluetoothDisabled,
+ )
- ConstraintId.APP_PLAYING_MEDIA -> R.string.constraint_choose_app_playing_media
+ ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.MobileOff)
- ConstraintId.APP_NOT_PLAYING_MEDIA -> R.string.constraint_choose_app_not_playing_media
+ ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOff)
- ConstraintId.MEDIA_NOT_PLAYING -> R.string.constraint_choose_media_not_playing
+ ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector(
+ Icons.Outlined.SignalWifiStatusbarNull,
+ )
+
+ ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.WifiOff)
+
+ ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.KeyboardHide)
+
+ ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen)
+
+ ConstraintId.LOCK_SCREEN_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen)
+
+ ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar)
+
+ ConstraintId.NOTIFICATION_PANEL_SHOWING -> ComposeIconInfo.Vector(
+ Icons.Outlined.NotificationsOff,
+ )
+
+ ConstraintId.IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd)
+
+ ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call)
+
+ ConstraintId.PHONE_RINGING -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd)
+
+ ConstraintId.NOTIFICATION_POSTED ->
+ ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff)
+
+ else -> getIcon(constraintId)
+ }
+
+ fun getTitleStringId(constraintId: ConstraintId): Int = when (constraintId) {
+ ConstraintId.APP_IN_FOREGROUND -> R.string.constraint_choose_app_foreground
+
+ ConstraintId.APP_PLAYING_MEDIA -> R.string.constraint_choose_app_playing_media
ConstraintId.MEDIA_PLAYING -> R.string.constraint_choose_media_playing
ConstraintId.BT_DEVICE_CONNECTED -> R.string.constraint_choose_bluetooth_device_connected
- ConstraintId.BT_DEVICE_DISCONNECTED ->
- R.string.constraint_choose_bluetooth_device_disconnected
-
ConstraintId.SCREEN_ON -> R.string.constraint_choose_screen_on_description
- ConstraintId.SCREEN_OFF -> R.string.constraint_choose_screen_off_description
-
ConstraintId.DISPLAY_RESOLUTION -> R.string.constraint_choose_display_resolution
ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> R.string.constraint_choose_orientation_portrait
@@ -287,28 +274,16 @@ object ConstraintUtils {
ConstraintId.FLASHLIGHT_ON -> R.string.constraint_flashlight_on
- ConstraintId.FLASHLIGHT_OFF -> R.string.constraint_flashlight_off
-
ConstraintId.WIFI_ON -> R.string.constraint_wifi_on
- ConstraintId.WIFI_OFF -> R.string.constraint_wifi_off
-
ConstraintId.WIFI_CONNECTED -> R.string.constraint_wifi_connected
- ConstraintId.WIFI_DISCONNECTED -> R.string.constraint_wifi_disconnected
-
ConstraintId.IME_CHOSEN -> R.string.constraint_ime_chosen
- ConstraintId.IME_NOT_CHOSEN -> R.string.constraint_ime_not_chosen
-
ConstraintId.KEYBOARD_SHOWING -> R.string.constraint_keyboard_showing
- ConstraintId.KEYBOARD_NOT_SHOWING -> R.string.constraint_keyboard_not_showing
-
ConstraintId.DEVICE_IS_LOCKED -> R.string.constraint_device_is_locked
- ConstraintId.DEVICE_IS_UNLOCKED -> R.string.constraint_device_is_unlocked
-
ConstraintId.IN_PHONE_CALL -> R.string.constraint_in_phone_call
ConstraintId.NOT_IN_PHONE_CALL -> R.string.constraint_not_in_phone_call
@@ -323,84 +298,68 @@ object ConstraintUtils {
ConstraintId.CHARGING -> R.string.constraint_charging
- ConstraintId.DISCHARGING -> R.string.constraint_discharging
-
ConstraintId.HINGE_CLOSED -> R.string.constraint_hinge_closed
ConstraintId.HINGE_OPEN -> R.string.constraint_hinge_open
ConstraintId.LOCK_SCREEN_SHOWING -> R.string.constraint_lock_screen_showing
- ConstraintId.LOCK_SCREEN_NOT_SHOWING -> R.string.constraint_lock_screen_not_showing
-
ConstraintId.NOTIFICATION_PANEL_SHOWING ->
R.string.constraint_notification_panel_showing
- ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING ->
- R.string.constraint_notification_panel_not_showing
+ ConstraintId.NOTIFICATION_POSTED -> R.string.constraint_notification_posted
ConstraintId.TIME -> R.string.constraint_time
}
+ @StringRes
+ fun getNotificationFieldLabel(field: NotificationField): Int = when (field) {
+ NotificationField.PACKAGE -> R.string.notification_field_package_label
+ NotificationField.TITLE -> R.string.notification_field_title_label
+ NotificationField.TEXT -> R.string.notification_field_text_label
+ }
+
+ @StringRes
+ fun getTextMatchModeLabel(matchMode: TextMatchMode): Int = when (matchMode) {
+ TextMatchMode.CONTAINS -> R.string.notification_match_mode_contains
+ TextMatchMode.EXACT -> R.string.notification_match_mode_exact
+ }
+
fun Constraint.getDependency(): Set {
return when (data) {
- is ConstraintData.AppInForeground,
- is ConstraintData.AppNotInForeground,
- -> setOf(ConstraintDependency.FOREGROUND_APP)
+ is ConstraintData.AppInForeground -> setOf(ConstraintDependency.FOREGROUND_APP)
- is ConstraintData.AppNotPlayingMedia,
- is ConstraintData.AppPlayingMedia,
- -> setOf(ConstraintDependency.APP_PLAYING_MEDIA)
+ is ConstraintData.AppPlayingMedia -> setOf(ConstraintDependency.APP_PLAYING_MEDIA)
- is ConstraintData.BtDeviceConnected,
- is ConstraintData.BtDeviceDisconnected,
- -> setOf(ConstraintDependency.CONNECTED_BT_DEVICES)
+ is ConstraintData.BtDeviceConnected -> setOf(ConstraintDependency.CONNECTED_BT_DEVICES)
- is ConstraintData.Charging,
- is ConstraintData.Discharging,
- -> setOf(ConstraintDependency.CHARGING_STATE)
+ is ConstraintData.Charging -> setOf(ConstraintDependency.CHARGING_STATE)
- is ConstraintData.DeviceIsLocked,
- is ConstraintData.DeviceIsUnlocked,
- -> setOf(ConstraintDependency.DEVICE_LOCKED_STATE)
+ is ConstraintData.DeviceIsLocked -> setOf(ConstraintDependency.DEVICE_LOCKED_STATE)
- is ConstraintData.FlashlightOff,
- is ConstraintData.FlashlightOn,
- -> setOf(ConstraintDependency.FLASHLIGHT_STATE)
+ is ConstraintData.FlashlightOn -> setOf(ConstraintDependency.FLASHLIGHT_STATE)
- is ConstraintData.ImeChosen,
- is ConstraintData.ImeNotChosen,
- -> setOf(ConstraintDependency.CHOSEN_IME)
+ is ConstraintData.ImeChosen -> setOf(ConstraintDependency.CHOSEN_IME)
is ConstraintData.InPhoneCall,
is ConstraintData.NotInPhoneCall,
is ConstraintData.PhoneRinging,
-> setOf(ConstraintDependency.PHONE_STATE)
- is ConstraintData.MediaPlaying,
- is ConstraintData.NoMediaPlaying,
- -> setOf(ConstraintDependency.MEDIA_PLAYING)
+ is ConstraintData.MediaPlaying -> setOf(ConstraintDependency.MEDIA_PLAYING)
is ConstraintData.OrientationCustom,
is ConstraintData.OrientationLandscape,
is ConstraintData.OrientationPortrait,
-> setOf(ConstraintDependency.DISPLAY_ORIENTATION)
- is ConstraintData.ScreenOff,
- is ConstraintData.ScreenOn,
- -> setOf(ConstraintDependency.SCREEN_STATE)
+ is ConstraintData.ScreenOn -> setOf(ConstraintDependency.SCREEN_STATE)
- is ConstraintData.WifiConnected,
- is ConstraintData.WifiDisconnected,
- -> setOf(ConstraintDependency.WIFI_SSID)
+ is ConstraintData.WifiConnected -> setOf(ConstraintDependency.WIFI_SSID)
- is ConstraintData.WifiOff,
- is ConstraintData.WifiOn,
- -> setOf(ConstraintDependency.WIFI_STATE)
+ is ConstraintData.WifiOn -> setOf(ConstraintDependency.WIFI_STATE)
- is ConstraintData.LockScreenShowing,
- is ConstraintData.LockScreenNotShowing,
- -> setOf(ConstraintDependency.LOCK_SCREEN_SHOWING)
+ is ConstraintData.LockScreenShowing -> setOf(ConstraintDependency.LOCK_SCREEN_SHOWING)
is ConstraintData.Time -> setOf(
ConstraintDependency.FOREGROUND_APP,
@@ -411,9 +370,7 @@ object ConstraintUtils {
is ConstraintData.HingeOpen -> setOf(ConstraintDependency.HINGE_STATE)
- ConstraintData.KeyboardNotShowing,
- ConstraintData.KeyboardShowing,
- -> setOf(ConstraintDependency.KEYBOARD_VISIBLE)
+ ConstraintData.KeyboardShowing -> setOf(ConstraintDependency.KEYBOARD_VISIBLE)
is ConstraintData.PhysicalOrientation -> setOf(
ConstraintDependency.PHYSICAL_ORIENTATION,
@@ -421,12 +378,13 @@ object ConstraintUtils {
is ConstraintData.RingerMode -> setOf(ConstraintDependency.RINGER_MODE)
- ConstraintData.NotificationPanelNotShowing, ConstraintData.NotificationPanelShowing ->
- setOf(
- ConstraintDependency.NOTIFICATION_PANEL_STATE,
- )
+ ConstraintData.NotificationPanelShowing ->
+ setOf(ConstraintDependency.NOTIFICATION_PANEL_STATE)
is ConstraintData.DisplayResolution -> setOf(ConstraintDependency.DISPLAY_RESOLUTIONS)
+
+ is ConstraintData.NotificationPosted ->
+ setOf(ConstraintDependency.POSTED_NOTIFICATIONS)
}
}
}
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 dfe69d2467..ffe841aba1 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
@@ -9,10 +9,12 @@ 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.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
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
@@ -42,11 +44,19 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme
import io.github.sds100.keymapper.base.keymaps.ShortcutModel
import io.github.sds100.keymapper.base.keymaps.ShortcutRow
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
-import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText
+import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem
+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.base.utils.ui.drawable
import io.github.sds100.keymapper.common.utils.State
import io.github.sds100.keymapper.system.camera.CameraLens
+/**
+ * The height of the row between group cards. The last card has a spacer of the same height so
+ * the height of the items stays constant while dragging.
+ */
+private val linkRowHeight = 40.dp
+
@Composable
fun ConstraintsScreen(
modifier: Modifier = Modifier,
@@ -67,11 +77,18 @@ fun ConstraintsScreen(
ConstraintsScreen(
modifier = modifier,
state = state,
+ onAddClick = { viewModel.addConstraint(groupUid = null) },
+ onAddToGroupClick = viewModel::addConstraint,
onRemoveClick = viewModel::onRemoveClick,
+ onRemoveGroupClick = viewModel::onRemoveGroupClick,
+ onNotClick = viewModel::onNotClick,
onFixErrorClick = viewModel::onFixError,
onClickShortcut = viewModel::onClickShortcut,
- onAddClick = viewModel::addConstraint,
onSelectMode = viewModel::onSelectMode,
+ onSelectGroupMode = viewModel::onSelectGroupMode,
+ onRenameGroup = viewModel::onRenameGroup,
+ onExpandedChange = viewModel::onExpandedChange,
+ onMoveGroup = viewModel::onMoveGroup,
)
}
@@ -80,39 +97,94 @@ private fun ConstraintsScreen(
modifier: Modifier = Modifier,
state: State,
onAddClick: () -> Unit = {},
+ onAddToGroupClick: (String) -> Unit = {},
onRemoveClick: (String) -> Unit = {},
+ onRemoveGroupClick: (String) -> Unit = {},
+ onNotClick: (String) -> Unit = {},
onFixErrorClick: (String) -> Unit = {},
onClickShortcut: (ConstraintData) -> Unit = {},
onSelectMode: (ConstraintMode) -> Unit = {},
+ onSelectGroupMode: (String, ConstraintMode) -> Unit = { _, _ -> },
+ onRenameGroup: (groupUid: String, name: String) -> Unit = { _, _ -> },
+ onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit = { _, _ -> },
+ onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> },
) {
- var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
var constraintToDelete by rememberSaveable { mutableStateOf(null) }
+ var groupToDelete by rememberSaveable { mutableStateOf(null) }
+ var groupToRename by rememberSaveable { mutableStateOf(null) }
- if (showDeleteDialog && constraintToDelete != null) {
+ if (constraintToDelete != null) {
AlertDialog(
- onDismissRequest = { showDeleteDialog = false },
+ 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!!)
- showDeleteDialog = false
- }) {
+ 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 = { showDeleteDialog = false }) {
+ TextButton(onClick = { groupToDelete = null }) {
Text(stringResource(R.string.constraint_list_delete_cancel))
}
},
)
}
- when (val state = state) {
+ val groups = ((state as? State.Data)?.data as? ConfigConstraintsState.Loaded)
+ ?.groups
+ .orEmpty()
+ val renameModel = groups.find { it.uid == groupToRename }
+
+ if (renameModel != null) {
+ TextFieldDialog(
+ title = stringResource(R.string.constraint_group_rename_hint),
+ submitButtonText = stringResource(R.string.pos_save),
+ initialText = renameModel.name.orEmpty(),
+ canBeEmpty = true,
+ onSubmitClick = { newText ->
+ onRenameGroup(renameModel.uid, newText)
+ null
+ },
+ onDismissRequest = { groupToRename = null },
+ )
+ }
+
+ when (state) {
State.Loading -> Loading()
+
is State.Data -> Surface(modifier = modifier) {
Column {
when (val data = state.data) {
@@ -152,40 +224,40 @@ private fun ConstraintsScreen(
}
is ConfigConstraintsState.Loaded -> {
- Spacer(Modifier.height(8.dp))
-
- if (data.constraintList.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
-
- Text(
- modifier = Modifier.padding(horizontal = 16.dp),
- text = stringResource(R.string.constraint_list_explanation_header),
- style = MaterialTheme.typography.titleSmall,
- )
- }
-
- Spacer(Modifier.height(8.dp))
-
- ConstraintList(
+ ConstraintGroupList(
modifier = Modifier.weight(1f),
- constraintList = data.constraintList,
- shortcuts = data.shortcuts,
- onRemoveClick = {
- constraintToDelete = it
- showDeleteDialog = true
- },
+ state = data,
+ onAddToGroupClick = onAddToGroupClick,
+ onRemoveClick = { constraintToDelete = it },
+ onRemoveGroupClick = { groupToDelete = it },
+ onNotClick = onNotClick,
onFixErrorClick = onFixErrorClick,
onClickShortcut = onClickShortcut,
+ onSelectGroupMode = onSelectGroupMode,
+ onRenameGroupClick = { groupToRename = it },
+ onExpandedChange = onExpandedChange,
+ onMoveGroup = onMoveGroup,
)
- if (data.constraintList.size > 1) {
- ConstraintModeRow(
+ if (data.groups.size > 1) {
+ Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 8.dp),
- mode = data.selectedMode,
- onSelectMode = onSelectMode,
- )
+ .padding(horizontal = 16.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = stringResource(R.string.constraint_groups_mode_title),
+ style = MaterialTheme.typography.titleSmall,
+ )
+
+ ConstraintModeButtons(
+ modifier = Modifier.width(160.dp),
+ mode = data.mode,
+ onSelectMode = onSelectMode,
+ )
+ }
}
}
}
@@ -207,37 +279,6 @@ private fun ConstraintsScreen(
}
}
-@Composable
-private fun ConstraintModeRow(
- modifier: Modifier = Modifier,
- mode: ConstraintMode,
- onSelectMode: (ConstraintMode) -> Unit,
-) {
- Column(modifier = modifier) {
- Text(
- modifier = Modifier.padding(horizontal = 8.dp),
- text = stringResource(R.string.constraint_mode_title),
- style = MaterialTheme.typography.labelLarge,
- )
-
- Row(modifier = Modifier.fillMaxWidth()) {
- RadioButtonText(
- modifier = Modifier.weight(1f),
- isSelected = mode == ConstraintMode.AND,
- text = stringResource(R.string.constraint_mode_and),
- onSelected = { onSelectMode(ConstraintMode.AND) },
- )
-
- RadioButtonText(
- modifier = Modifier.weight(1f),
- isSelected = mode == ConstraintMode.OR,
- text = stringResource(R.string.constraint_mode_or),
- onSelected = { onSelectMode(ConstraintMode.OR) },
- )
- }
- }
-}
-
@Composable
private fun Loading(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -246,50 +287,130 @@ private fun Loading(modifier: Modifier = Modifier) {
}
@Composable
-private fun ConstraintList(
+private fun ConstraintGroupList(
modifier: Modifier = Modifier,
- constraintList: List,
- shortcuts: Set>,
+ state: ConfigConstraintsState.Loaded,
+ onAddToGroupClick: (String) -> Unit,
onRemoveClick: (String) -> Unit,
+ onRemoveGroupClick: (String) -> Unit,
+ onNotClick: (String) -> Unit,
onFixErrorClick: (String) -> Unit,
onClickShortcut: (ConstraintData) -> Unit,
+ onSelectGroupMode: (String, ConstraintMode) -> Unit,
+ onRenameGroupClick: (groupUid: String) -> Unit,
+ onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit,
+ onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit,
) {
val lazyListState = rememberLazyListState()
+ val groupUids = state.groups.map { it.uid }
+
+ // Only the groups can be dragged. Not the row of shortcuts.
+ val dragDropState = rememberDragDropState(
+ lazyListState = lazyListState,
+ keys = groupUids,
+ onMove = onMoveGroup,
+ )
+
+ val orderedGroups = dragDropState.ordered(state.groups) { it.uid }
+
+ val linkText = when (state.mode) {
+ ConstraintMode.AND -> stringResource(R.string.constraint_mode_and)
+ ConstraintMode.OR -> stringResource(R.string.constraint_mode_or)
+ }
- // Use dragContainer rather than .draggable() modifier because that causes
- // dragging the first item to be always be dropped in the next position.
LazyColumn(
modifier = modifier,
state = lazyListState,
contentPadding = PaddingValues(vertical = 8.dp),
) {
- items(
- constraintList,
- key = { item -> item.id },
- contentType = { _ -> "constraint" },
- ) { model ->
- ConstraintListItem(
- modifier = Modifier.fillMaxWidth(),
- model = model,
- onRemoveClick = { onRemoveClick(model.id) },
- onFixClick = { onFixErrorClick(model.id) },
+ item(key = "header") {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
+ text = stringResource(R.string.constraint_list_explanation),
+ style = MaterialTheme.typography.titleSmall,
+ textAlign = TextAlign.Center,
)
}
- if (shortcuts.isNotEmpty()) {
+ itemsIndexed(
+ orderedGroups,
+ key = { _, group -> group.uid },
+ contentType = { _, _ -> "constraint_group" },
+ ) { index, group ->
+ DraggableItem(
+ dragDropState = dragDropState,
+ key = group.uid,
+ ) { isDragging ->
+ Column {
+ ConstraintGroupItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ model = group,
+ isExpanded = group.isExpanded,
+ isDraggingEnabled = orderedGroups.size > 1,
+ isDragging = isDragging,
+ isReorderingEnabled = state.groups.size > 1,
+ dragDropState = dragDropState,
+ onExpandedChange = { expanded -> onExpandedChange(group.uid, expanded) },
+ onSelectMode = { onSelectGroupMode(group.uid, it) },
+ onAddConstraintClick = { onAddToGroupClick(group.uid) },
+ onRenameClick = { onRenameGroupClick(group.uid) },
+ onDeleteClick = { onRemoveGroupClick(group.uid) },
+ onRemoveConstraintClick = onRemoveClick,
+ onFixConstraintClick = onFixErrorClick,
+ onNotClick = onNotClick,
+ onMoveUp = if (index > 0) {
+ { onMoveGroup(index, index - 1) }
+ } else {
+ null
+ },
+ onMoveDown = if (index < orderedGroups.lastIndex) {
+ { onMoveGroup(index, index + 1) }
+ } else {
+ null
+ },
+ )
+
+ if (index < orderedGroups.lastIndex) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(linkRowHeight),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = linkText,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ } else {
+ // Important! Keep the height of the item constant while dragging.
+ // If the height changes while dragging it can lead to janky behavior.
+ Spacer(Modifier.height(linkRowHeight))
+ }
+ }
+ }
+ }
+
+ if (state.shortcuts.isNotEmpty()) {
item(key = "shortcuts", contentType = "shortcuts") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.recently_used_constraints),
style = MaterialTheme.typography.titleSmall,
)
+
Spacer(Modifier.height(8.dp))
ShortcutRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
- shortcuts = shortcuts,
+ shortcuts = state.shortcuts,
onClick = { onClickShortcut(it) },
)
}
@@ -318,35 +439,58 @@ private fun EmptyPreview() {
}
}
-@Preview
+@Preview(heightDp = 1200)
@Composable
private fun LoadedPreview() {
KeyMapperTheme {
val ctx = LocalContext.current
+ val flashlightConstraint = ConstraintListItemModel(
+ id = "1",
+ icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn),
+ text = "Flashlight is on",
+ isNot = true,
+ error = "Flashlight not found",
+ isErrorFixable = true,
+ )
+
+ val appConstraint = ConstraintListItemModel(
+ id = "2",
+ icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)),
+ text = "Key Mapper is in foreground",
+ )
+
+ val wifiConstraint = ConstraintListItemModel(
+ id = "3",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.Wifi),
+ text = "Wi-Fi is on",
+ )
+
ConstraintsScreen(
state = State.Data(
ConfigConstraintsState.Loaded(
- constraintList = listOf(
- ConstraintListItemModel(
- id = "1",
- icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn),
- constraintModeLink = ConstraintMode.AND,
- text = "Flashlight is on",
- error = "Flashlight not found",
- isErrorFixable = true,
+ groups = listOf(
+ ConstraintGroupListItemModel(
+ uid = "group1",
+ mode = ConstraintMode.AND,
+ constraints = listOf(flashlightConstraint, appConstraint),
+ description = "Flashlight is not on AND Key Mapper is in foreground",
+ ),
+ ConstraintGroupListItemModel(
+ uid = "group2",
+ name = "Foreground or wifi",
+ mode = ConstraintMode.OR,
+ constraints = listOf(appConstraint, wifiConstraint),
+ description = "Key Mapper is in foreground OR Wi-Fi is on",
),
- ConstraintListItemModel(
- id = "2",
- icon = ComposeIconInfo.Drawable(
- ctx.drawable(R.mipmap.ic_launcher_round),
- ),
- constraintModeLink = null,
- text = "Key Mapper in foreground",
- error = null,
- isErrorFixable = true,
+ ConstraintGroupListItemModel(
+ uid = "group3",
+ mode = ConstraintMode.AND,
+ constraints = listOf(wifiConstraint),
+ description = "Wi-Fi is on",
),
),
+ mode = ConstraintMode.OR,
shortcuts = setOf(
ShortcutModel(
icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn),
@@ -354,7 +498,6 @@ private fun LoadedPreview() {
data = ConstraintData.FlashlightOn(lens = CameraLens.BACK),
),
),
- selectedMode = ConstraintMode.AND,
),
),
)
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 a9650ccbd5..dfff01cde4 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
@@ -27,7 +27,7 @@ class CreateConstraintUseCaseImpl @Inject constructor(
override fun isSupported(constraint: ConstraintId): KMError? {
when (constraint) {
- ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> {
+ ConstraintId.FLASHLIGHT_ON -> {
if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null &&
cameraAdapter.getFlashInfo(CameraLens.FRONT) == null
) {
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 a2eb148de6..bb722e7ee7 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
@@ -14,6 +14,7 @@ import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter
import io.github.sds100.keymapper.system.lock.LockScreenAdapter
import io.github.sds100.keymapper.system.media.MediaAdapter
import io.github.sds100.keymapper.system.network.NetworkAdapter
+import io.github.sds100.keymapper.system.notifications.NotificationAdapter
import io.github.sds100.keymapper.system.phone.PhoneAdapter
import io.github.sds100.keymapper.system.power.PowerAdapter
import io.github.sds100.keymapper.system.volume.VolumeAdapter
@@ -36,6 +37,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor(
private val powerAdapter: PowerAdapter,
private val foldableAdapter: FoldableAdapter,
private val volumeAdapter: VolumeAdapter,
+ private val notificationAdapter: NotificationAdapter,
) : DetectConstraintsUseCase {
@AssistedFactory
@@ -56,6 +58,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor(
powerAdapter,
foldableAdapter,
volumeAdapter,
+ notificationAdapter,
)
override fun onDependencyChanged(dependency: ConstraintDependency): Flow {
@@ -121,6 +124,9 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor(
ConstraintDependency.NOTIFICATION_PANEL_STATE ->
accessibilityService.isNotificationShadeExpanded.map { dependency }
+ ConstraintDependency.POSTED_NOTIFICATIONS ->
+ notificationAdapter.activeNotifications.map { dependency }
+
ConstraintDependency.DISPLAY_RESOLUTIONS -> displayAdapter.supportedResolutions.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 5424ad8c9a..b8795342d3 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
@@ -18,6 +18,7 @@ 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
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -192,7 +193,7 @@ private fun DisplayResolutionConstraintBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewWithModes() {
KeyMapperTheme {
@@ -200,6 +201,7 @@ private fun PreviewWithModes() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
DisplayResolutionConstraintBottomSheet(
@@ -219,7 +221,7 @@ private fun PreviewWithModes() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewCustomOnly() {
KeyMapperTheme {
@@ -227,6 +229,7 @@ private fun PreviewCustomOnly() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
DisplayResolutionConstraintBottomSheet(
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt
new file mode 100644
index 0000000000..6632749938
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt
@@ -0,0 +1,33 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.system.notifications.PostedNotification
+
+/**
+ * Matches a notification against a [ConstraintData.NotificationPosted].
+ */
+object NotificationConstraintMatcher {
+
+ fun matches(
+ notification: PostedNotification,
+ data: ConstraintData.NotificationPosted,
+ ): Boolean = when (data) {
+ is ConstraintData.NotificationPosted.FromApp ->
+ notification.packageName == data.packageName
+
+ is ConstraintData.NotificationPosted.Title -> matchesText(notification.title, data)
+
+ is ConstraintData.NotificationPosted.Text -> matchesText(notification.text, data)
+ }
+
+ private fun matchesText(
+ fieldValue: String?,
+ data: ConstraintData.NotificationPosted.TextMatch,
+ ): Boolean {
+ fieldValue ?: return false
+
+ return when (data.matchMode) {
+ TextMatchMode.CONTAINS -> fieldValue.contains(data.text, ignoreCase = true)
+ TextMatchMode.EXACT -> fieldValue.equals(data.text, ignoreCase = true)
+ }
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt
new file mode 100644
index 0000000000..22ea843cb6
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt
@@ -0,0 +1,23 @@
+package io.github.sds100.keymapper.base.constraints
+
+enum class NotificationField {
+ PACKAGE,
+ TITLE,
+ TEXT,
+ ;
+
+ /**
+ * Whether the value is free text that the user types rather than an app they pick. Only these
+ * fields have a [TextMatchMode].
+ */
+ val isFreeText: Boolean
+ get() = this != PACKAGE
+
+ companion object {
+ fun of(data: ConstraintData.NotificationPosted): NotificationField = when (data) {
+ is ConstraintData.NotificationPosted.FromApp -> PACKAGE
+ is ConstraintData.NotificationPosted.Title -> TITLE
+ is ConstraintData.NotificationPosted.Text -> TEXT
+ }
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt
new file mode 100644
index 0000000000..21df90a481
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt
@@ -0,0 +1,12 @@
+package io.github.sds100.keymapper.base.constraints
+
+import kotlinx.serialization.Serializable
+
+/**
+ * How the text the user entered is compared against the value of a notification field.
+ */
+@Serializable
+enum class TextMatchMode {
+ CONTAINS,
+ EXACT,
+}
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 e0a1415247..e17627be2f 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
@@ -21,6 +21,7 @@ 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.TextButton
import androidx.compose.material3.TimePicker
@@ -267,7 +268,7 @@ private fun TimePickerDialog(state: TimePickerState, onDismiss: () -> Unit, onCo
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun Preview() {
KeyMapperTheme {
@@ -275,6 +276,7 @@ private fun Preview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TimeConstraintBottomSheet(
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt
index 01d28b35b1..c9d9dbb442 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt
@@ -19,6 +19,8 @@ import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityServic
import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper
import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+import io.github.sds100.keymapper.base.vibration.vibrate
import io.github.sds100.keymapper.common.utils.State
import io.github.sds100.keymapper.common.utils.dataOrNull
import io.github.sds100.keymapper.data.Keys
@@ -81,7 +83,10 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor(
if (groupUid == null) {
add(
DetectKeyMapModel(
- keyMap = keyMap,
+ // Disabled actions must never be performed.
+ keyMap = keyMap.copy(
+ actionList = keyMap.actionList.filter { it.isEnabled },
+ ),
groupConstraintStates = constraintStates,
),
)
@@ -95,7 +100,7 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor(
val group = groupMap[groupUid]!!
groupUid = group.parentUid
- if (group.constraintState.constraints.isNotEmpty()) {
+ if (group.constraintState.allConstraints.isNotEmpty()) {
constraintStates.add(group.constraintState)
}
@@ -134,11 +139,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor(
}
}
- override val keyMapsToTriggerFromOtherApps: Flow> =
- allKeyMapList.map { keyMapList ->
- keyMapList.filter { it.keyMap.trigger.triggerFromOtherApps }.map { it.keyMap }
- }.flowOn(Dispatchers.Default)
-
override val defaultLongPressDelay: Flow =
preferenceRepository.get(Keys.defaultLongPressDelay)
.map { it ?: PreferenceDefaults.LONG_PRESS_DELAY }
@@ -183,6 +183,10 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor(
vibrator.vibrate(duration)
}
+ override fun vibrate(effect: VibrateEffect) {
+ vibrator.vibrate(effect)
+ }
+
override fun imitateKeyEvent(
keyCode: Int,
metaState: Int,
@@ -259,7 +263,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor(
interface DetectKeyMapsUseCase {
val allKeyMapList: Flow>
val requestFingerprintGestureDetection: Flow
- val keyMapsToTriggerFromOtherApps: Flow>
val defaultLongPressDelay: Flow
val defaultDoublePressDelay: Flow
@@ -270,6 +273,7 @@ interface DetectKeyMapsUseCase {
fun showTriggeredToast()
fun vibrate(duration: Long)
+ fun vibrate(effect: VibrateEffect)
val currentTime: Long
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt
index 36959ac9b6..7f4af9c185 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt
@@ -26,6 +26,7 @@ import io.github.sds100.keymapper.base.trigger.Trigger
import io.github.sds100.keymapper.base.trigger.TriggerKey
import io.github.sds100.keymapper.base.trigger.TriggerMode
import io.github.sds100.keymapper.base.trigger.detectWithScancode
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.EvdevDeviceInfo
import io.github.sds100.keymapper.common.utils.minusFlag
import io.github.sds100.keymapper.common.utils.withFlag
@@ -800,7 +801,7 @@ class KeyMapAlgorithm(
var awaitingLongPress = false
var showToast = false
val detectedShortPressTriggers = mutableSetOf()
- val vibrateDurations = mutableListOf()
+ val vibrateEffects = mutableListOf()
val errorSnapshot = performActionsUseCase.getErrorSnapshot()
@@ -953,7 +954,7 @@ class KeyMapAlgorithm(
awaitingLongPress = true
if (trigger.vibrate && trigger.longPressDoubleVibration) {
- vibrateDurations.add(vibrateDuration(trigger))
+ vibrateEffects.add(resolveVibrateEffect(trigger))
}
val oldJob = parallelTriggerLongPressJobs[triggerIndex]
@@ -1033,13 +1034,14 @@ class KeyMapAlgorithm(
showToast = true
}
- val vibrateDuration = when {
- trigger.vibrate -> vibrateDuration(trigger)
- forceVibrate.value -> defaultVibrateDuration.value
- else -> -1L
+ val vibrateEffect = when {
+ trigger.vibrate -> resolveVibrateEffect(trigger)
+ forceVibrate.value ->
+ VibrateEffect.CustomDuration(defaultVibrateDuration.value)
+ else -> null
}
- vibrateDurations.add(vibrateDuration)
+ vibrateEffect?.let { vibrateEffects.add(it) }
}
}
}
@@ -1049,11 +1051,11 @@ class KeyMapAlgorithm(
useCase.showTriggeredToast()
}
- if (vibrateDurations.isNotEmpty()) {
+ if (vibrateEffects.isNotEmpty()) {
if (forceVibrate.value) {
useCase.vibrate(defaultVibrateDuration.value)
} else {
- vibrateDurations.maxOrNull()?.let {
+ resolveWinningVibrateEffect(vibrateEffects)?.let {
useCase.vibrate(it)
}
}
@@ -1112,7 +1114,7 @@ class KeyMapAlgorithm(
val detectedSequenceTriggerIndexes = mutableListOf()
val detectedParallelTriggerIndexes = mutableListOf()
- val vibrateDurations = mutableListOf()
+ val vibrateEffects = mutableListOf()
val imitateKeyAfterDoublePressTimeout = mutableListOf()
@@ -1254,7 +1256,7 @@ class KeyMapAlgorithm(
triggerActions[triggerIndex].forEach { _ ->
if (trigger.vibrate) {
- vibrateDurations.add(vibrateDuration(trigger))
+ vibrateEffects.add(resolveVibrateEffect(trigger))
}
}
@@ -1451,7 +1453,7 @@ class KeyMapAlgorithm(
if (forceVibrate.value) {
useCase.vibrate(defaultVibrateDuration.value)
} else {
- vibrateDurations.maxOrNull()?.let {
+ resolveWinningVibrateEffect(vibrateEffects)?.let {
useCase.vibrate(it)
}
}
@@ -1638,7 +1640,7 @@ class KeyMapAlgorithm(
private fun performActionsOnFailedDoublePress(event: AlgoEvent): Boolean {
var showToast = false
val detectedTriggerIndexes = mutableListOf()
- val vibrateDurations = mutableListOf()
+ val vibrateEffects = mutableListOf()
performActionsOnFailedDoublePress.forEach { triggerIndex ->
if (triggers[triggerIndex].keys.last().matchesEvent(event.withShortPress)) {
@@ -1649,7 +1651,7 @@ class KeyMapAlgorithm(
}
if (triggers[triggerIndex].vibrate) {
- vibrateDurations.add(vibrateDuration(triggers[triggerIndex]))
+ vibrateEffects.add(resolveVibrateEffect(triggers[triggerIndex]))
}
}
}
@@ -1672,7 +1674,7 @@ class KeyMapAlgorithm(
if (forceVibrate.value) {
useCase.vibrate(defaultVibrateDuration.value)
} else {
- vibrateDurations.maxOrNull()?.let {
+ resolveWinningVibrateEffect(vibrateEffects)?.let {
useCase.vibrate(it)
}
}
@@ -1737,7 +1739,7 @@ class KeyMapAlgorithm(
forceVibrate.value ||
triggers[triggerIndex].longPressDoubleVibration
) {
- useCase.vibrate(vibrateDuration(triggers[triggerIndex]))
+ useCase.vibrate(resolveVibrateEffect(triggers[triggerIndex]))
}
if (triggers[triggerIndex].showToast) {
@@ -1773,7 +1775,7 @@ class KeyMapAlgorithm(
forceVibrate.value ||
triggers[triggerIndex].longPressDoubleVibration
) {
- useCase.vibrate(vibrateDuration(triggers[triggerIndex]))
+ useCase.vibrate(resolveVibrateEffect(triggers[triggerIndex]))
}
if (triggers[triggerIndex].showToast) {
@@ -1884,8 +1886,18 @@ class KeyMapAlgorithm(
private fun doublePressTimeout(trigger: Trigger): Long =
trigger.doublePressDelay?.toLong() ?: defaultDoublePressDelay.value
- private fun vibrateDuration(trigger: Trigger): Long =
- trigger.vibrateDuration?.toLong() ?: defaultVibrateDuration.value
+ private fun resolveVibrateEffect(trigger: Trigger): VibrateEffect =
+ trigger.vibrateEffect ?: VibrateEffect.CustomDuration(defaultVibrateDuration.value)
+
+ /**
+ * When several triggers fire at once, vibrate only once using the "strongest" of their
+ * effects: a predefined effect (a deliberate choice) wins over a plain duration, and
+ * amongst durations the longest one wins, matching the previous duration-only behaviour.
+ */
+ private fun resolveWinningVibrateEffect(effects: List): VibrateEffect? =
+ effects.filterIsInstance().firstOrNull()
+ ?: effects.filterIsInstance()
+ .maxByOrNull { it.durationMs }
private fun sequenceTriggerTimeout(trigger: Trigger): Long =
trigger.sequenceTriggerTimeout?.toLong() ?: defaultSequenceTriggerTimeout.value
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt
index 4952391d4a..74f675d8eb 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt
@@ -6,6 +6,7 @@ import io.github.sds100.keymapper.base.actions.RepeatMode
import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase
import io.github.sds100.keymapper.base.constraints.isSatisfied
import io.github.sds100.keymapper.base.keymaps.KeyMap
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.utils.InputEventAction
import io.github.sds100.keymapper.data.PreferenceDefaults
import kotlinx.coroutines.CoroutineScope
@@ -58,7 +59,7 @@ abstract class SimpleMappingController(
if (!keyMap.isEnabled) return
if (keyMap.actionList.isEmpty()) return
- if (keyMap.constraintState.constraints.isNotEmpty()) {
+ if (keyMap.constraintState.allConstraints.isNotEmpty()) {
val constraintSnapshot = detectConstraintsUseCase.getSnapshot()
if (!constraintSnapshot.isSatisfied(keyMap.constraintState)) return
}
@@ -120,7 +121,8 @@ abstract class SimpleMappingController(
if (keyMap.vibrate || forceVibrate.value) {
detectMappingUseCase.vibrate(
- keyMap.vibrateDuration?.toLong() ?: defaultVibrateDuration.value,
+ keyMap.vibrateEffect
+ ?: VibrateEffect.CustomDuration(defaultVibrateDuration.value),
)
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt
index 01755046ef..59dbc7a0b6 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt
@@ -23,9 +23,9 @@ class TriggerKeyMapFromOtherAppsController(
init {
coroutineScope.launch {
- detectKeyMapsUseCase.keyMapsToTriggerFromOtherApps.collectLatest { keyMaps ->
+ detectKeyMapsUseCase.allKeyMapList.collectLatest { keyMaps ->
reset()
- this@TriggerKeyMapFromOtherAppsController.keyMapList = keyMaps
+ this@TriggerKeyMapFromOtherAppsController.keyMapList = keyMaps.map { it.keyMap }
}
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt
index e0e77eb0c6..10dcd98621 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt
@@ -1221,7 +1221,7 @@ private fun SystemBridgeStartErrorDialog(
)
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewSystemBridgeStartErrorDialog() {
KeyMapperTheme {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt
index 9519f59a12..6e93bb6e89 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt
@@ -353,6 +353,8 @@ class SystemBridgeAutoStarter @Inject constructor(
it is SystemBridgeConnectionState.Connected
}
}
+
+ showSystemBridgeStartedNotification()
} catch (_: TimeoutCancellationException) {
showAutoStartFailedNotification()
}
@@ -479,4 +481,20 @@ class SystemBridgeAutoStarter @Inject constructor(
notificationAdapter.showNotification(model)
}
+
+ private fun showSystemBridgeStartedNotification() {
+ val model = NotificationModel(
+ id = ID_SYSTEM_BRIDGE_STATUS,
+ title = getString(R.string.expert_mode_setup_notification_system_bridge_started_title),
+ text = getString(R.string.expert_mode_setup_notification_system_bridge_started_text),
+ channel = CHANNEL_SETUP_ASSISTANT,
+ icon = R.drawable.offline_bolt_24px,
+ onGoing = false,
+ showOnLockscreen = false,
+ autoCancel = true,
+ timeout = 5000,
+ )
+
+ notificationAdapter.showNotification(model)
+ }
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt
index 0ca10b7535..815ee4cfdf 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt
@@ -1,8 +1,7 @@
package io.github.sds100.keymapper.base.groups
-import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper
-import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper
import io.github.sds100.keymapper.base.constraints.ConstraintState
+import io.github.sds100.keymapper.base.constraints.ConstraintStateEntityMapper
import io.github.sds100.keymapper.data.entities.GroupEntity
data class Group(
@@ -15,28 +14,29 @@ data class Group(
object GroupEntityMapper {
fun fromEntity(entity: GroupEntity): Group {
- val constraintList =
- entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet()
-
- val constraintMode = ConstraintModeEntityMapper.fromEntity(entity.constraintMode)
+ val constraintState = ConstraintStateEntityMapper.fromEntity(
+ entity.constraintList,
+ entity.constraintMode,
+ )
return Group(
uid = entity.uid,
name = entity.name,
- constraintState = ConstraintState(constraintList, constraintMode),
+ constraintState = constraintState,
parentUid = entity.parentUid,
lastOpenedDate = entity.lastOpenedDate ?: System.currentTimeMillis(),
)
}
fun toEntity(group: Group): GroupEntity {
+ val (constraintList, constraintMode) =
+ ConstraintStateEntityMapper.toEntity(group.constraintState)
+
return GroupEntity(
uid = group.uid,
name = group.name,
- constraintList = group.constraintState.constraints.map {
- ConstraintEntityMapper.toEntity(it)
- },
- constraintMode = ConstraintModeEntityMapper.toEntity(group.constraintState.mode),
+ constraintList = constraintList,
+ constraintMode = constraintMode,
parentUid = group.parentUid,
lastOpenedDate = group.lastOpenedDate,
)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt
index a38e78f492..0304f76cdf 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -14,10 +13,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Close
-import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -30,15 +27,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.base.compose.KeyMapperTheme
import io.github.sds100.keymapper.base.constraints.ConstraintMode
+import io.github.sds100.keymapper.base.utils.ui.compose.CompactChip
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.base.utils.ui.compose.ErrorCompactChip
import io.github.sds100.keymapper.base.utils.ui.drawable
import io.github.sds100.keymapper.common.utils.KMError
@@ -50,11 +48,13 @@ fun GroupConstraintRow(
parentConstraintCount: Int,
onNewConstraintClick: () -> Unit = {},
onRemoveConstraintClick: (String) -> Unit = {},
- onFixConstraintClick: (KMError) -> Unit = {},
+ onNotConstraintClick: (String) -> Unit = {},
enabled: Boolean = true,
) {
val maxChipWidth = 300.dp
+ val chipColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)
+
FlowRow(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -63,54 +63,72 @@ fun GroupConstraintRow(
) {
for ((index, constraint) in constraints.withIndex()) {
when (constraint) {
- is ComposeChipModel.Normal ->
- CompositionLocalProvider(
- LocalContentColor provides MaterialTheme.colorScheme.onSurface,
- ) {
- ConstraintButton(
- modifier = Modifier.widthIn(max = maxChipWidth),
- text = constraint.text,
- onRemoveClick = { onRemoveConstraintClick(constraint.id) },
- // Only allow clicking on error chips
- enabled = enabled,
- icon = {
- if (constraint.icon is ComposeIconInfo.Vector) {
- Icon(
- modifier = Modifier
- .size(24.dp)
- .padding(end = 8.dp),
- imageVector = constraint.icon.imageVector,
- contentDescription = null,
- )
- } else if (constraint.icon is ComposeIconInfo.Drawable) {
- Icon(
- modifier = Modifier
- .size(24.dp)
- .padding(end = 8.dp),
- painter = rememberDrawablePainter(
- constraint.icon.drawable,
- ),
- contentDescription = null,
- tint = Color.Unspecified,
- )
- }
- },
- )
- }
+ is ComposeChipModel.Normal -> {
+ CompactChip(
+ modifier = Modifier.widthIn(max = maxChipWidth),
+ text = constraint.text,
+ containerColor = chipColor,
+ enabled = true,
+ onClick = { onNotConstraintClick(constraint.id) },
+ icon = {
+ if (constraint.icon is ComposeIconInfo.Vector) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .padding(end = 8.dp),
+ imageVector = constraint.icon.imageVector,
+ contentDescription = null,
+ )
+ } else if (constraint.icon is ComposeIconInfo.Drawable) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .padding(end = 8.dp),
+ painter = rememberDrawablePainter(
+ constraint.icon.drawable,
+ ),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ )
+ }
+ },
+ actionContent = {
+ IconButton(
+ modifier = Modifier.size(16.dp),
+ onClick = { onRemoveConstraintClick(constraint.id) },
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = stringResource(
+ R.string.home_group_delete_constraint_button,
+ ),
+ )
+ }
+ },
+ )
+ }
is ComposeChipModel.Error ->
- CompositionLocalProvider(
- LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer,
- ) {
- ConstraintErrorButton(
- modifier = Modifier.widthIn(max = maxChipWidth),
- text = constraint.text,
- onClick = { onFixConstraintClick(constraint.error) },
- onRemoveClick = { onRemoveConstraintClick(constraint.id) },
- // Only allow clicking on error chips
- enabled = enabled,
- )
- }
+ ErrorCompactChip(
+ modifier = Modifier.widthIn(max = maxChipWidth),
+ text = constraint.text,
+ containerColor = chipColor,
+ onClick = { onNotConstraintClick(constraint.id) },
+ enabled = true,
+ actionContent = {
+ IconButton(
+ modifier = Modifier.size(16.dp),
+ onClick = { onRemoveConstraintClick(constraint.id) },
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = stringResource(
+ R.string.home_group_delete_constraint_button,
+ ),
+ )
+ }
+ },
+ )
}
if (index < constraints.lastIndex) {
@@ -189,116 +207,6 @@ private fun NewConstraintButton(
}
}
-@Composable
-private fun ConstraintButton(
- modifier: Modifier = Modifier,
- text: String,
- icon: @Composable () -> Unit,
- onRemoveClick: () -> Unit = {},
- enabled: Boolean,
-) {
- CompositionLocalProvider(
- LocalMinimumInteractiveComponentSize provides 16.dp,
- ) {
- Surface(
- modifier = modifier,
- shape = MaterialTheme.shapes.small,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
- ) {
- Row(
- modifier = Modifier
- .padding(vertical = 4.dp, horizontal = 8.dp)
- .heightIn(min = 24.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- icon()
-
- Text(
- modifier = Modifier.weight(1f, fill = false),
- text = text,
- maxLines = 1,
- style = MaterialTheme.typography.titleSmall,
- overflow = TextOverflow.Ellipsis,
- )
-
- Spacer(modifier = Modifier.width(4.dp))
-
- IconButton(
- modifier = Modifier.size(16.dp),
- onClick = onRemoveClick,
- enabled = enabled,
- ) {
- Icon(
- imageVector = Icons.Rounded.Close,
- contentDescription = stringResource(
- R.string.home_group_delete_constraint_button,
- ),
- )
- }
- }
- }
- }
-}
-
-@Composable
-private fun ConstraintErrorButton(
- modifier: Modifier = Modifier,
- text: String,
- onClick: () -> Unit,
- onRemoveClick: () -> Unit = {},
- enabled: Boolean,
-) {
- CompositionLocalProvider(
- LocalMinimumInteractiveComponentSize provides 16.dp,
- ) {
- Surface(
- modifier = modifier,
- shape = MaterialTheme.shapes.small,
- color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
- onClick = onClick,
- enabled = enabled,
- ) {
- Row(
- modifier = Modifier
- .padding(vertical = 4.dp, horizontal = 8.dp)
- .heightIn(min = 24.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- modifier = Modifier
- .size(24.dp)
- .padding(end = 8.dp),
- imageVector = Icons.Rounded.ErrorOutline,
- contentDescription = null,
- )
-
- Text(
- modifier = Modifier.weight(1f, fill = false),
- text = text,
- maxLines = 1,
- style = MaterialTheme.typography.titleSmall,
- overflow = TextOverflow.Ellipsis,
- )
-
- Spacer(modifier = Modifier.width(4.dp))
-
- IconButton(
- modifier = Modifier.size(16.dp),
- onClick = onRemoveClick,
- enabled = enabled,
- ) {
- Icon(
- imageVector = Icons.Rounded.Close,
- contentDescription = stringResource(
- R.string.home_group_delete_constraint_button,
- ),
- )
- }
- }
- }
- }
-}
-
@Preview
@Composable
private fun PreviewEmpty() {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt
index 446bfffc55..6d7e0d5471 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt
@@ -2,4 +2,10 @@ package io.github.sds100.keymapper.base.groups
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
-data class GroupListItemModel(val uid: String, val name: String, val icon: ComposeIconInfo? = null)
+data class GroupListItemModel(
+ val uid: String,
+ val name: String,
+ val icon: ComposeIconInfo? = null,
+ val isEnabled: Boolean = true,
+ val isError: Boolean = false,
+)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt
index 11a7b1b197..e629bbc2a7 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt
@@ -32,6 +32,7 @@ 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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -135,13 +136,16 @@ fun GroupRow(
onClick = { onGroupClick(group.uid) },
text = group.name,
enabled = enabled,
+ isGroupEnabled = group.isEnabled,
+ isGroupError = group.isError,
icon = {
when (group.icon) {
is ComposeIconInfo.Drawable -> {
Icon(
modifier = Modifier
.size(24.dp)
- .padding(end = 8.dp),
+ .padding(end = 8.dp)
+ .alpha(if (group.isEnabled) 1.0f else 0.38f),
painter = rememberDrawablePainter(group.icon.drawable),
contentDescription = null,
tint = Color.Unspecified,
@@ -275,29 +279,54 @@ private fun GroupButton(
text: String,
icon: @Composable () -> Unit,
enabled: Boolean,
+ isGroupEnabled: Boolean = true,
+ isGroupError: Boolean = false,
) {
- Surface(
- modifier = modifier,
- onClick = onClick,
- shape = MaterialTheme.shapes.medium,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
- enabled = enabled,
- ) {
- Row(
- modifier = Modifier
- .padding(vertical = 6.dp, horizontal = 12.dp)
- .heightIn(min = 24.dp),
- verticalAlignment = Alignment.CenterVertically,
+ val containerColor: Color
+ val contentColor: Color
+
+ when {
+ // If the group is disabled then never show the error color.
+ !isGroupEnabled -> {
+ containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
+ contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
+ }
+
+ isGroupError -> {
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ contentColor = MaterialTheme.colorScheme.onErrorContainer
+ }
+
+ else -> {
+ containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
+ contentColor = MaterialTheme.colorScheme.onSurface
+ }
+ }
+
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ Surface(
+ modifier = modifier,
+ onClick = onClick,
+ shape = MaterialTheme.shapes.medium,
+ color = containerColor,
+ enabled = enabled,
) {
- icon()
+ Row(
+ modifier = Modifier
+ .padding(vertical = 6.dp, horizontal = 12.dp)
+ .heightIn(min = 24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ icon()
- Text(
- text = text,
- maxLines = 1,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurface,
- overflow = TextOverflow.Ellipsis,
- )
+ Text(
+ text = text,
+ maxLines = 1,
+ style = MaterialTheme.typography.titleSmall,
+ color = contentColor,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
}
}
@@ -322,6 +351,85 @@ private fun PreviewEmptyDisabled() {
}
}
+@Preview
+@Composable
+private fun PreviewDisabledGroup() {
+ KeyMapperTheme {
+ Surface {
+ GroupRow(
+ groups = listOf(
+ GroupListItemModel(
+ uid = "1",
+ name = "Torch",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.Lock),
+ isEnabled = false,
+ ),
+ ),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewDisabledGroupDrawable() {
+ val ctx = LocalContext.current
+
+ KeyMapperTheme {
+ Surface {
+ GroupRow(
+ groups = listOf(
+ GroupListItemModel(
+ uid = "1",
+ name = "Torch",
+ icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)),
+ isEnabled = false,
+ ),
+ ),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewErrorGroup() {
+ KeyMapperTheme {
+ Surface {
+ GroupRow(
+ groups = listOf(
+ GroupListItemModel(
+ uid = "1",
+ name = "Torch",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.Lock),
+ isError = true,
+ ),
+ ),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewDisabledErrorGroup() {
+ KeyMapperTheme {
+ Surface {
+ GroupRow(
+ groups = listOf(
+ GroupListItemModel(
+ uid = "1",
+ name = "Torch",
+ icon = ComposeIconInfo.Vector(Icons.Outlined.Lock),
+ isEnabled = false,
+ isError = true,
+ ),
+ ),
+ )
+ }
+ }
+}
+
@Preview
@Composable
private fun PreviewOneItem() {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt
new file mode 100644
index 0000000000..aad7b05c7d
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt
@@ -0,0 +1,3 @@
+package io.github.sds100.keymapper.base.groups
+
+data class GroupWithState(val group: Group, val isEnabled: Boolean, val isError: Boolean)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt
index 7fdb6b42ae..f7befb8a5a 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt
@@ -48,7 +48,7 @@ fun BackupFilePickerDialog(
Column(modifier = Modifier.fillMaxWidth()) {
if (files.isEmpty()) {
Text(
- modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
+ modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.home_import_no_backups_found_in_downloads),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -95,7 +95,7 @@ private fun BackupFileListRow(modifier: Modifier = Modifier, name: String, onCli
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewBackupFilePickerDialog() {
KeyMapperTheme {
@@ -112,7 +112,7 @@ private fun PreviewBackupFilePickerDialog() {
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewBackupFilePickerDialogEmpty() {
KeyMapperTheme {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt
index 83cbf4229b..c296627565 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt
@@ -2,22 +2,24 @@ package io.github.sds100.keymapper.base.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate
import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase
import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase
import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase
import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate
+import io.github.sds100.keymapper.base.onboarding.WhatsNewState
import io.github.sds100.keymapper.base.sorting.SortKeyMapsUseCase
import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCase
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.DialogResponse
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
-import io.github.sds100.keymapper.base.utils.ui.showDialog
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
abstract class BaseHomeViewModel(
@@ -56,14 +58,29 @@ abstract class BaseHomeViewModel(
)
}
- init {
- viewModelScope.launch {
- onboarding.showWhatsNew.collect { showWhatsNew ->
- if (showWhatsNew) {
- showWhatsNewDialog()
- }
- }
+ private val manuallyShowWhatsNew = MutableStateFlow(false)
+
+ val whatsNewState: StateFlow = combine(
+ onboarding.showWhatsNew,
+ manuallyShowWhatsNew,
+ ) { showWhatsNew, manualShow ->
+ if (showWhatsNew || manualShow) {
+ WhatsNewState(
+ versionName = onboarding.appVersionName,
+ notes = onboarding.getWhatsNewNotes(),
+ )
+ } else {
+ null
}
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
+ fun onWhatsNewClick() {
+ manuallyShowWhatsNew.value = true
+ }
+
+ fun onDismissWhatsNew() {
+ onboarding.showedWhatsNew()
+ manuallyShowWhatsNew.value = false
}
fun launchSettings() {
@@ -77,24 +94,6 @@ abstract class BaseHomeViewModel(
navigate("about", NavDestination.About)
}
}
-
- private suspend fun showWhatsNewDialog() {
- val dialog = DialogModel.Alert(
- title = getString(R.string.whats_new),
- message = onboarding.getWhatsNewText(),
- positiveButtonText = getString(R.string.pos_ok),
- neutralButtonText = getString(R.string.neutral_changelog),
- )
-
- // don't return if they dismiss the dialog because this is common behaviour.
- val response = showDialog("whats-new", dialog)
-
- if (response == DialogResponse.NEUTRAL) {
- showDialog("url_changelog", DialogModel.OpenUrl(getString(R.string.url_changelog)))
- }
-
- onboarding.showedWhatsNew()
- }
}
enum class SelectedKeyMapsEnabled {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt
new file mode 100644
index 0000000000..a73b71caf7
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt
@@ -0,0 +1,152 @@
+package io.github.sds100.keymapper.base.home
+
+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.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import io.github.sds100.keymapper.base.R
+import io.github.sds100.keymapper.base.compose.KeyMapperTheme
+import io.github.sds100.keymapper.base.trigger.TriggerError
+import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog
+import io.github.sds100.keymapper.common.utils.KMError
+
+data class FixErrorsDialogState(val errors: List)
+
+sealed class FixErrorsDialogId {
+ data class Group(val uid: String) : FixErrorsDialogId()
+ data class KeyMap(val uid: String) : FixErrorsDialogId()
+}
+
+@Composable
+fun FixErrorsDialog(
+ state: FixErrorsDialogState,
+ onFixClick: (KeyMapError) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ CustomDialog(
+ title = stringResource(R.string.dialog_title_home_fix_error),
+ confirmButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(stringResource(R.string.pos_done))
+ }
+ },
+ onDismissRequest = onDismissRequest,
+ ) {
+ Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ ErrorSection(
+ title = stringResource(R.string.trigger_header),
+ errors = state.errors.filterIsInstance(),
+ onFixClick = onFixClick,
+ )
+ ErrorSection(
+ title = stringResource(R.string.action_list_header),
+ errors = state.errors.filterIsInstance(),
+ onFixClick = onFixClick,
+ )
+ ErrorSection(
+ title = stringResource(R.string.constraint_list_header),
+ errors = state.errors.filterIsInstance(),
+ onFixClick = onFixClick,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ErrorSection(
+ title: String,
+ errors: List,
+ onFixClick: (KeyMapError) -> Unit,
+) {
+ if (errors.isEmpty()) {
+ return
+ }
+
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ )
+
+ for (item in errors) {
+ ErrorListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ text = item.message,
+ isFixable = item.isFixable,
+ onFixClick = { onFixClick(item) },
+ )
+ }
+}
+
+@Composable
+private fun ErrorListItem(
+ modifier: Modifier = Modifier,
+ text: String,
+ isFixable: Boolean,
+ onFixClick: () -> Unit = {},
+) {
+ Row(modifier = modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ FilledTonalButton(
+ onClick = onFixClick,
+ enabled = isFixable,
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Text(text = stringResource(R.string.button_fix))
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewFixErrorsDialog() {
+ KeyMapperTheme {
+ FixErrorsDialog(
+ state = FixErrorsDialogState(
+ errors = listOf(
+ KeyMapError.Trigger(
+ TriggerError.DND_ACCESS_DENIED,
+ "Grant Key Mapper access to Do Not Disturb",
+ ),
+ KeyMapError.Action(
+ KMError.NoCompatibleImeChosen,
+ "Choose a compatible keyboard",
+ isFixable = true,
+ ),
+ KeyMapError.Constraint(
+ KMError.AppNotFound("com.example.app"),
+ "App not found",
+ isFixable = false,
+ ),
+ ),
+ ),
+ onFixClick = {},
+ onDismissRequest = {},
+ )
+ }
+}
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 85b6af1a55..418b72d2ff 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
@@ -66,7 +66,6 @@ import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.groups.GroupListItemModel
import io.github.sds100.keymapper.base.sorting.SortBottomSheet
import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
-import io.github.sds100.keymapper.base.trigger.TriggerError
import io.github.sds100.keymapper.base.utils.ShareUtils
import io.github.sds100.keymapper.base.utils.ui.compose.CollapsableFloatingActionButton
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel
@@ -84,8 +83,10 @@ fun HomeKeyMapListScreen(
modifier: Modifier = Modifier,
viewModel: KeyMapListViewModel,
snackbarState: SnackbarHostState,
+ headerExtraContent: @Composable () -> Unit,
onSettingsClick: () -> Unit,
onAboutClick: () -> Unit,
+ onWhatsNewClick: () -> Unit,
finishActivity: () -> Unit,
fabBottomPadding: Dp,
) {
@@ -134,6 +135,16 @@ fun HomeKeyMapListScreen(
)
}
+ val fixErrorsDialogState by viewModel.fixErrorsDialogState.collectAsStateWithLifecycle()
+
+ fixErrorsDialogState?.let { dialogState ->
+ FixErrorsDialog(
+ state = dialogState,
+ onFixClick = viewModel::onFixErrorClick,
+ onDismissRequest = viewModel::onDismissFixErrorsDialog,
+ )
+ }
+
val fixKeyEventActionState by viewModel.fixKeyEventActionState.collectAsStateWithLifecycle()
if (fixKeyEventActionState != null) {
@@ -160,9 +171,7 @@ fun HomeKeyMapListScreen(
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val uriHandler = LocalUriHandler.current
val ctx = LocalContext.current
- val helpUrl = stringResource(R.string.url_quick_start_guide)
var keyMapListBottomPadding by remember { mutableStateOf(100.dp) }
val lazyListState = rememberLazyListState()
@@ -196,13 +205,15 @@ fun HomeKeyMapListScreen(
KeyMapListHeader(
state = state.appBarState,
scrollBehavior = scrollBehavior,
+ extraContent = headerExtraContent,
onFixWarningClick = viewModel::onFixWarningClick,
onNewGroupClick = viewModel::onNewGroupClick,
onGroupClick = viewModel::onGroupClick,
onNewConstraintClick = viewModel::onNewGroupConstraintClick,
onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick,
+ onNotConstraintClick = viewModel::onNotGroupConstraintClick,
onConstraintModeChanged = viewModel::onGroupConstraintModeChanged,
- onFixConstraintClick = viewModel::onFixClick,
+ onFixClick = viewModel::onFixGroupConstraintsClick,
onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged,
)
},
@@ -212,7 +223,6 @@ fun HomeKeyMapListScreen(
onLongClickKeyMap = viewModel::onKeyMapCardLongClick,
onSelectedChange = viewModel::onKeyMapSelectedChanged,
onFixClick = viewModel::onFixClick,
- onTriggerErrorClick = viewModel::onFixTriggerError,
bottomListPadding = keyMapListBottomPadding,
)
},
@@ -223,7 +233,7 @@ fun HomeKeyMapListScreen(
onSettingsClick = onSettingsClick,
onAboutClick = onAboutClick,
onSortClick = { viewModel.showSortBottomSheet = true },
- onHelpClick = { uriHandler.openUriSafe(ctx, helpUrl) },
+ onWhatsNewClick = onWhatsNewClick,
onExportClick = viewModel::onExportClick,
onImportClick = {
if (LeanbackUtils.isTvDevice(ctx)) {
@@ -288,7 +298,7 @@ fun HomeKeyMapListScreen(
}
@Composable
-private fun HomeKeyMapListScreen(
+fun HomeKeyMapListScreen(
modifier: Modifier = Modifier,
snackbarState: SnackbarHostState = SnackbarHostState(),
appBarContent: @Composable () -> Unit,
@@ -448,7 +458,7 @@ private fun BugReportDialog(onDismissRequest: () -> Unit) {
}
@Composable
-private fun sampleList(): List {
+fun sameKeyMapListItems(): List {
val context = LocalContext.current
return listOf(
@@ -498,8 +508,8 @@ private fun sampleList(): List {
),
),
options = listOf("Vibrate"),
- triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED),
- extraInfo = "Disabled • No trigger",
+ isEnabled = true,
+ hasError = true,
),
),
KeyMapListItemModel(
@@ -531,8 +541,8 @@ private fun sampleList(): List {
"Vibrate",
"Vibrate when keys are initially pressed and again when long pressed",
),
- triggerErrors = emptyList(),
- extraInfo = null,
+ isEnabled = true,
+ hasError = false,
),
),
KeyMapListItemModel(
@@ -548,6 +558,7 @@ private fun sampleList(): List {
drawable = context.drawable(R.drawable.ic_launcher_web),
),
"Open Key Mapper",
+ isEnabled = false,
),
),
constraintMode = ConstraintMode.AND,
@@ -558,11 +569,12 @@ private fun sampleList(): List {
drawable = context.drawable(R.drawable.ic_launcher_web),
),
"Key Mapper is not open",
+ isEnabled = false,
),
),
options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = null,
+ isEnabled = false,
+ hasError = false,
),
),
KeyMapListItemModel(
@@ -583,8 +595,8 @@ private fun sampleList(): List {
constraintMode = ConstraintMode.AND,
constraints = emptyList(),
options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = null,
+ isEnabled = true,
+ hasError = false,
),
),
KeyMapListItemModel(
@@ -597,8 +609,8 @@ private fun sampleList(): List {
constraintMode = ConstraintMode.OR,
constraints = emptyList(),
options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = "Disabled • No trigger",
+ isEnabled = true,
+ hasError = false,
),
),
)
@@ -617,7 +629,7 @@ private fun PreviewSelectingKeyMaps() {
showThisGroup = false,
)
- val listState = State.Data(sampleList())
+ val listState = State.Data(sameKeyMapListItems())
KeyMapperTheme {
HomeKeyMapListScreen(
@@ -626,7 +638,7 @@ private fun PreviewSelectingKeyMaps() {
KeyMapList(
lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = 4),
listItems = listState,
- header = { KeyMapListHeader(state = appBarState) },
+ header = { KeyMapListHeader(state = appBarState, extraContent = {}) },
footerText = stringResource(R.string.home_key_map_list_footer_text),
isSelectable = true,
)
@@ -656,7 +668,7 @@ private fun PreviewKeyMapsRunning() {
isPaused = false,
)
- val listState = State.Data(sampleList())
+ val listState = State.Data(sameKeyMapListItems())
KeyMapperTheme {
HomeKeyMapListScreen(
@@ -670,7 +682,7 @@ private fun PreviewKeyMapsRunning() {
KeyMapList(
lazyListState = rememberLazyListState(),
listItems = listState,
- header = { KeyMapListHeader(state = appBarState) },
+ header = { KeyMapListHeader(state = appBarState, extraContent = {}) },
footerText = stringResource(R.string.home_key_map_list_footer_text),
isSelectable = false,
)
@@ -693,7 +705,7 @@ private fun PreviewKeyMapsPaused() {
isPaused = true,
)
- val listState = State.Data(sampleList())
+ val listState = State.Data(sameKeyMapListItems())
KeyMapperTheme {
HomeKeyMapListScreen(
@@ -707,7 +719,7 @@ private fun PreviewKeyMapsPaused() {
KeyMapList(
lazyListState = rememberLazyListState(),
listItems = listState,
- header = { KeyMapListHeader(state = appBarState) },
+ header = { KeyMapListHeader(state = appBarState, extraContent = {}) },
footerText = stringResource(R.string.home_key_map_list_footer_text),
isSelectable = false,
)
@@ -749,7 +761,7 @@ private fun PreviewKeyMapsWarnings() {
isPaused = true,
)
- val listState = State.Data(sampleList())
+ val listState = State.Data(sameKeyMapListItems())
KeyMapperTheme {
HomeKeyMapListScreen(
@@ -763,7 +775,7 @@ private fun PreviewKeyMapsWarnings() {
KeyMapList(
lazyListState = rememberLazyListState(),
listItems = listState,
- header = { KeyMapListHeader(state = appBarState) },
+ header = { KeyMapListHeader(state = appBarState, extraContent = {}) },
footerText = stringResource(R.string.home_key_map_list_footer_text),
isSelectable = false,
)
@@ -811,7 +823,7 @@ private fun PreviewKeyMapsWarningsEmpty() {
KeyMapList(
lazyListState = rememberLazyListState(),
listItems = listState,
- header = { KeyMapListHeader(state = appBarState) },
+ header = { KeyMapListHeader(state = appBarState, extraContent = {}) },
footerText = stringResource(R.string.home_key_map_list_footer_text),
isSelectable = false,
@@ -849,7 +861,7 @@ private fun PreviewKeyMapsLoading() {
KeyMapList(
lazyListState = rememberLazyListState(),
listItems = listState,
- header = { KeyMapListHeader(state = appBarState) },
+ header = { KeyMapListHeader(state = appBarState, extraContent = {}) },
footerText = stringResource(R.string.home_key_map_list_footer_text),
isSelectable = false,
)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/ImportDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ImportDialog.kt
index dfcb7ed9fb..146ec5ff3a 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/ImportDialog.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ImportDialog.kt
@@ -55,7 +55,7 @@ fun ImportDialog(
)
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun ImportDialogPreview() {
KeyMapperTheme {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt
new file mode 100644
index 0000000000..dd1294503c
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt
@@ -0,0 +1,25 @@
+package io.github.sds100.keymapper.base.home
+
+import io.github.sds100.keymapper.base.trigger.TriggerError
+import io.github.sds100.keymapper.common.utils.KMError
+
+sealed class KeyMapError {
+ abstract val message: String
+ abstract val isFixable: Boolean
+
+ data class Trigger(val error: TriggerError, override val message: String) : KeyMapError() {
+ override val isFixable: Boolean = error.isFixable
+ }
+
+ data class Action(
+ val error: KMError,
+ override val message: String,
+ override val isFixable: Boolean,
+ ) : KeyMapError()
+
+ data class Constraint(
+ val error: KMError,
+ override val message: String,
+ override val isFixable: Boolean,
+ ) : KeyMapError()
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt
index 79d33b8888..4399416ee3 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt
@@ -1,12 +1,13 @@
package io.github.sds100.keymapper.base.home
import io.github.sds100.keymapper.base.groups.Group
+import io.github.sds100.keymapper.base.groups.GroupWithState
import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.common.utils.State
data class KeyMapGroup(
val group: Group?,
- val subGroups: List,
+ val subGroups: List,
val parents: List,
val keyMaps: State>,
)
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 c895fca2e3..56d9958e33 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
@@ -29,9 +29,9 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
-import androidx.compose.material.icons.automirrored.rounded.HelpOutline
import androidx.compose.material.icons.automirrored.rounded.Sort
import androidx.compose.material.icons.rounded.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
@@ -100,7 +100,7 @@ fun KeyMapListAppBar(
onSettingsClick: () -> Unit = {},
onAboutClick: () -> Unit = {},
onSortClick: () -> Unit = {},
- onHelpClick: () -> Unit = {},
+ onWhatsNewClick: () -> Unit = {},
onTogglePausedClick: () -> Unit = {},
onExportClick: () -> Unit = {},
onImportClick: () -> Unit = {},
@@ -135,7 +135,8 @@ fun KeyMapListAppBar(
var expandedDropdown by rememberSaveable { mutableStateOf(false) }
AppBarActions(
- onHelpClick,
+ showWhatsNew = true,
+ onWhatsNewClick,
onMenuClick = { expandedDropdown = true },
dropdownMenuContent = {
RootGroupDropdownMenu(
@@ -246,7 +247,8 @@ fun KeyMapListAppBar(
var expandedDropdown by rememberSaveable { mutableStateOf(false) }
AppBarActions(
- onHelpClick,
+ showWhatsNew = false,
+ onWhatsNewClick,
onMenuClick = { expandedDropdown = true },
dropdownMenuContent = {
ChildGroupDropdownMenu(
@@ -410,16 +412,19 @@ private fun SelectingAppBar(
@Composable
private fun AppBarActions(
- onHelpClick: () -> Unit,
+ showWhatsNew: Boolean,
+ onWhatsNewClick: () -> Unit,
onMenuClick: () -> Unit = {},
dropdownMenuContent: @Composable () -> Unit,
) {
Row {
- IconButton(onClick = onHelpClick) {
- Icon(
- Icons.AutoMirrored.Rounded.HelpOutline,
- contentDescription = stringResource(R.string.home_app_bar_help),
- )
+ if (showWhatsNew) {
+ IconButton(onClick = onWhatsNewClick) {
+ Icon(
+ Icons.Rounded.Campaign,
+ contentDescription = stringResource(R.string.home_app_bar_whats_new),
+ )
+ }
}
IconButton(onClick = onMenuClick) {
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 18ce75a488..b67df2198e 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
@@ -10,19 +10,18 @@ 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.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
-import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -34,17 +33,19 @@ import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.constraints.ConstraintMode
+import io.github.sds100.keymapper.base.constraints.ConstraintModeButtons
import io.github.sds100.keymapper.base.groups.GroupBreadcrumbRow
import io.github.sds100.keymapper.base.groups.GroupConstraintRow
import io.github.sds100.keymapper.base.groups.GroupListItemModel
import io.github.sds100.keymapper.base.groups.GroupRow
+import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton
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.base.utils.ui.compose.RadioButtonText
import io.github.sds100.keymapper.base.utils.ui.drawable
import io.github.sds100.keymapper.common.utils.KMError
@@ -59,13 +60,15 @@ fun KeyMapListHeader(
modifier: Modifier = Modifier,
state: KeyMapAppBarState,
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
+ extraContent: @Composable () -> Unit,
onFixWarningClick: (String) -> Unit = {},
onNewGroupClick: () -> Unit = {},
onGroupClick: (String?) -> Unit = {},
onNewConstraintClick: () -> Unit = {},
onRemoveConstraintClick: (String) -> Unit = {},
+ onNotConstraintClick: (String) -> Unit = {},
onConstraintModeChanged: (ConstraintMode) -> Unit = {},
- onFixConstraintClick: (KMError) -> 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
@@ -94,6 +97,7 @@ fun KeyMapListHeader(
is KeyMapAppBarState.RootGroup -> RootGroupHeader(
modifier = modifier.fillMaxWidth(),
state = state,
+ extraContent = extraContent,
containerColor = appBarContainerColor,
onFixWarningClick = onFixWarningClick,
onNewGroupClick = onNewGroupClick,
@@ -107,8 +111,9 @@ fun KeyMapListHeader(
onGroupClick = onGroupClick,
onNewConstraintClick = onNewConstraintClick,
onRemoveConstraintClick = onRemoveConstraintClick,
+ onNotConstraintClick = onNotConstraintClick,
onConstraintModeChanged = onConstraintModeChanged,
- onFixConstraintClick = onFixConstraintClick,
+ onFixClick = onFixClick,
onKeyMapsEnabledChange = onKeyMapsEnabledChange,
)
@@ -122,24 +127,25 @@ private fun RootGroupHeader(
modifier: Modifier = Modifier,
state: KeyMapAppBarState.RootGroup,
containerColor: Color,
+ extraContent: @Composable () -> Unit,
onFixWarningClick: (String) -> Unit,
onNewGroupClick: () -> Unit,
onGroupClick: (String) -> Unit,
) {
- Column(modifier) {
- AnimatedVisibility(visible = state.warnings.isNotEmpty()) {
- // Use separate Surfaces so the animation doesn't jump when they both disappear
- // going into selection mode.
- Surface(color = containerColor) {
+ Surface(modifier = modifier, color = containerColor) {
+ Column {
+ AnimatedVisibility(visible = state.warnings.isNotEmpty()) {
+ // Use separate Surfaces so the animation doesn't jump when they both disappear
+ // going into selection mode.
HomeWarningList(
modifier = Modifier.padding(bottom = 8.dp),
warnings = state.warnings,
onFixClick = onFixWarningClick,
)
}
- }
- Surface(color = containerColor) {
+ extraContent()
+
GroupRow(
modifier = Modifier
.padding(horizontal = 8.dp)
@@ -161,8 +167,9 @@ private fun ChildGroupHeader(
onGroupClick: (String?) -> Unit,
onNewConstraintClick: () -> Unit,
onRemoveConstraintClick: (String) -> Unit,
+ onNotConstraintClick: (String) -> Unit,
onConstraintModeChanged: (ConstraintMode) -> Unit,
- onFixConstraintClick: (KMError) -> Unit,
+ onFixClick: () -> Unit,
onKeyMapsEnabledChange: (Boolean) -> Unit,
) {
val enabled = !state.isEditingGroupName
@@ -174,7 +181,27 @@ private fun ChildGroupHeader(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
- Column {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (state.constraints.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.home_group_invert_constraint_hint),
+ style = MaterialTheme.typography.labelMedium,
+ )
+
+ if (state.constraints.any { it is ComposeChipModel.Error }) {
+ CompactErrorButton(onClick = onFixClick) {
+ Text(text = stringResource(R.string.button_fix))
+ }
+ }
+ }
+ }
+
GroupConstraintRow(
modifier = Modifier
.padding(horizontal = 8.dp)
@@ -182,14 +209,12 @@ private fun ChildGroupHeader(
constraints = state.constraints,
mode = state.constraintMode,
parentConstraintCount = state.parentConstraintCount,
- onFixConstraintClick = onFixConstraintClick,
onNewConstraintClick = onNewConstraintClick,
onRemoveConstraintClick = onRemoveConstraintClick,
+ onNotConstraintClick = onNotConstraintClick,
enabled = enabled,
)
- Spacer(Modifier.height(8.dp))
-
Row(
modifier = Modifier
.fillMaxWidth()
@@ -197,29 +222,20 @@ private fun ChildGroupHeader(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
+ val modeColors = SegmentedButtonDefaults.colors(
+ activeContainerColor = MaterialTheme.colorScheme.primary,
+ activeContentColor = MaterialTheme.colorScheme.onPrimary,
+ activeBorderColor = MaterialTheme.colorScheme.primary,
+ inactiveBorderColor = MaterialTheme.colorScheme.primary,
+ inactiveContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+
AnimatedVisibility(visible = state.constraints.size > 1) {
Row(verticalAlignment = Alignment.CenterVertically) {
- RadioButtonText(
- text = stringResource(R.string.constraint_mode_and),
- isSelected = state.constraintMode == ConstraintMode.AND,
- isEnabled = enabled,
- onSelected = {
- onConstraintModeChanged(ConstraintMode.AND)
- },
- )
-
- RadioButtonText(
- text = stringResource(R.string.constraint_mode_or),
- isSelected = state.constraintMode == ConstraintMode.OR,
- isEnabled = enabled,
- onSelected = {
- onConstraintModeChanged(ConstraintMode.OR)
- },
- )
-
- VerticalDivider(
- modifier = Modifier.height(24.dp),
- color = MaterialTheme.colorScheme.onPrimaryContainer,
+ ConstraintModeButtons(
+ mode = state.constraintMode,
+ onSelectMode = onConstraintModeChanged,
+ colors = modeColors,
)
}
}
@@ -348,7 +364,7 @@ private fun RootGroupHeaderPreview() {
KeyMapperTheme {
Surface {
- KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state)
+ KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {})
}
}
}
@@ -365,13 +381,13 @@ private fun RootGroupHeaderNoWarningsPreview() {
KeyMapperTheme(darkTheme = true) {
Surface {
- KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state)
+ KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {})
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@PreviewLightDark
@Composable
private fun ChildGroupHeaderPreview() {
val state = KeyMapAppBarState.ChildGroup(
@@ -388,7 +404,7 @@ private fun ChildGroupHeaderPreview() {
KeyMapperTheme {
Surface {
- KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state)
+ KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {})
}
}
}
@@ -411,7 +427,7 @@ private fun ChildGroupHeaderDarkPreview() {
KeyMapperTheme(darkTheme = true) {
Surface {
- KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state)
+ KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state, extraContent = {})
}
}
}
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
new file mode 100644
index 0000000000..5475cecad3
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt
@@ -0,0 +1,420 @@
+package io.github.sds100.keymapper.base.home
+
+import androidx.compose.foundation.combinedClickable
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.InlineTextContent
+import androidx.compose.foundation.text.appendInlineContent
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+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.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+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.constraints.ConstraintMode
+import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
+import io.github.sds100.keymapper.base.utils.ui.compose.CompactChip
+import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton
+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.base.utils.ui.compose.ErrorCompactChip
+
+@Composable
+fun KeyMapListItem(
+ modifier: Modifier = Modifier,
+ isSelectable: Boolean,
+ model: KeyMapListItemModel,
+ onClickKeyMap: () -> Unit,
+ onLongClickKeyMap: () -> Unit,
+ onSelectedChange: (Boolean) -> Unit,
+ onFixClick: () -> Unit,
+) {
+ val cardColors = if (model.content.isEnabled) {
+ CardDefaults.outlinedCardColors()
+ } else {
+ CardDefaults.outlinedCardColors(
+ contentColor =
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
+ )
+ }
+
+ OutlinedCard(
+ modifier = modifier,
+ colors = cardColors,
+ onClick = onClickKeyMap,
+ ) {
+ Row(
+ modifier = Modifier.combinedClickable(
+ onClick = onClickKeyMap,
+ onLongClick = onLongClickKeyMap,
+ ),
+ ) {
+ if (isSelectable) {
+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 16.dp,
+ ) {
+ Checkbox(
+ modifier = Modifier
+ .padding(start = 16.dp)
+ .align(Alignment.CenterVertically),
+ checked = model.isSelected,
+ onCheckedChange = onSelectedChange,
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 10.dp),
+ ) {
+ Row(
+ modifier = Modifier.heightIn(min = chipHeight),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TriggerDescription(
+ modifier = Modifier
+ .weight(1f)
+ .align(Alignment.CenterVertically),
+ triggerKeys = model.content.triggerKeys,
+ separator = model.content.triggerSeparatorIcon,
+ )
+
+ if (model.content.isEnabled && model.content.hasError) {
+ CompactErrorButton(onClick = onFixClick) {
+ Text(stringResource(R.string.button_fix))
+ }
+ }
+ }
+
+ if (model.content.actions.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ itemVerticalAlignment = Alignment.CenterVertically,
+ verticalArrangement = Arrangement.spacedBy(
+ 8.dp,
+ alignment = Alignment.CenterVertically,
+ ),
+ ) {
+ Text(
+ text = stringResource(R.string.action_list_header),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ for (chipModel in model.content.actions) {
+ ActionConstraintChip(
+ chipModel,
+ onFixClick = onFixClick,
+ )
+ }
+ }
+ }
+
+ if (model.content.constraints.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ itemVerticalAlignment = Alignment.CenterVertically,
+ verticalArrangement = Arrangement.spacedBy(
+ 8.dp,
+ alignment = Alignment.CenterVertically,
+ ),
+ ) {
+ Text(
+ text = stringResource(R.string.constraint_list_header),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ for ((index, chipModel) in model.content.constraints.withIndex()) {
+ ActionConstraintChip(
+ chipModel,
+ onFixClick = onFixClick,
+ )
+
+ if (index < model.content.constraints.lastIndex) {
+ when (model.content.constraintMode) {
+ ConstraintMode.AND -> Text(
+ text = stringResource(R.string.constraint_mode_and),
+ style = MaterialTheme.typography.labelMedium,
+ )
+
+ ConstraintMode.OR -> Text(
+ text = stringResource(R.string.constraint_mode_or),
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (model.content.options.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.heightIn(min = chipHeight),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OptionsDescription(
+ modifier = Modifier.fillMaxWidth(),
+ options = model.content.options,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+val chipHeight = 28.dp
+
+@Composable
+private fun TriggerDescription(
+ modifier: Modifier = Modifier,
+ triggerKeys: List,
+ separator: ImageVector,
+) {
+ val text = buildAnnotatedString {
+ pushStyle(
+ MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(),
+ )
+ append(stringResource(R.string.trigger_header))
+ pop()
+ append(" ")
+
+ if (triggerKeys.isEmpty()) {
+ append(stringResource(R.string.trigger_header_none))
+ } else {
+ for ((index, key) in triggerKeys.withIndex()) {
+ append(key)
+
+ if (index < triggerKeys.lastIndex) {
+ append(" ")
+ appendInlineContent("separator")
+ append(" ")
+ }
+ }
+ }
+ }
+
+ val inlineContent = mapOf(
+ "separator" to InlineTextContent(
+ placeholder = Placeholder(
+ width = 14.sp,
+ height = 14.sp,
+ placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
+ ),
+ ) {
+ Icon(imageVector = separator, contentDescription = null)
+ },
+ )
+
+ Text(
+ modifier = modifier,
+ text = text,
+ inlineContent = inlineContent,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+}
+
+@Composable
+private fun OptionsDescription(modifier: Modifier = Modifier, options: List) {
+ val dot = stringResource(R.string.middot)
+ val text = buildAnnotatedString {
+ pushStyle(
+ MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(),
+ )
+ append(stringResource(R.string.option_list_header))
+ pop()
+ append(" ")
+
+ for ((index, option) in options.withIndex()) {
+ append(option)
+
+ if (index < options.lastIndex) {
+ append(" $dot ")
+ }
+ }
+ }
+
+ Text(
+ modifier = modifier,
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+}
+
+@Composable
+private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: () -> Unit) {
+ when (model) {
+ is ComposeChipModel.Normal -> {
+ // Grey out disabled chips using the Material 3 alpha for disabled content.
+ val contentAlpha = if (model.isEnabled) 1f else 0.38f
+
+ CompactChip(
+ text = model.text,
+ contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
+ icon = model.icon?.let { icon ->
+ {
+ when (icon) {
+ is ComposeIconInfo.Drawable -> Icon(
+ modifier = Modifier
+ .alpha(contentAlpha)
+ .fillMaxHeight(),
+ painter = rememberDrawablePainter(icon.drawable),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ )
+
+ is ComposeIconInfo.Vector -> Icon(
+ modifier = Modifier
+ .fillMaxHeight(),
+ imageVector = icon.imageVector,
+ contentDescription = null,
+ )
+ }
+ }
+ },
+ )
+ }
+
+ is ComposeChipModel.Error -> ErrorCompactChip(
+ onClick = onFixClick,
+ text = model.text,
+ enabled = model.isFixable,
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun FullContentPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = false,
+ model = sameKeyMapListItems()[0],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MultilineOptionsPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = false,
+ model = sameKeyMapListItems()[1],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun DisabledPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = false,
+ model = sameKeyMapListItems()[2],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MinimalPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = false,
+ model = sameKeyMapListItems()[3],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun EmptyTriggerPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = false,
+ model = sameKeyMapListItems()[4],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SelectableCheckedPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = true,
+ model = sameKeyMapListItems()[0],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SelectableUncheckedPreview() {
+ KeyMapperTheme {
+ KeyMapListItem(
+ isSelectable = true,
+ model = sameKeyMapListItems()[4],
+ onClickKeyMap = {},
+ onLongClickKeyMap = {},
+ onSelectedChange = {},
+ onFixClick = {},
+ )
+ }
+}
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 929038eb47..ab3735aa07 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
@@ -6,7 +6,10 @@ import androidx.compose.material.icons.outlined.Add
import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot
import io.github.sds100.keymapper.base.actions.ActionUiHelper
+import io.github.sds100.keymapper.base.constraints.Constraint
import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot
+import io.github.sds100.keymapper.base.constraints.ConstraintGroup
+import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.constraints.ConstraintState
import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper
import io.github.sds100.keymapper.base.keymaps.ClickType
@@ -23,6 +26,7 @@ import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey
import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
import io.github.sds100.keymapper.base.trigger.Trigger
import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot
+import io.github.sds100.keymapper.base.trigger.TriggerKey
import io.github.sds100.keymapper.base.trigger.TriggerMode
import io.github.sds100.keymapper.base.trigger.getCodeLabel
import io.github.sds100.keymapper.base.utils.isFixable
@@ -63,6 +67,7 @@ class KeyMapListItemCreator(
is KeyEventTriggerKey -> keyEventTriggerKeyName(
key,
+ keyMap.trigger.keys,
showDeviceDescriptors,
)
@@ -77,38 +82,31 @@ class KeyMapListItemCreator(
val options = getTriggerOptionLabels(keyMap.trigger)
val actionChipList = getActionChipList(keyMap, showDeviceDescriptors, actionErrorSnapshot)
- val constraintChipList =
- buildConstraintChipList(keyMap.constraintState, constraintErrorSnapshot)
-
- val extraInfo = buildString {
- append(createExtraInfoString(keyMap, actionChipList, constraintChipList))
-
- if (keyMap.trigger.keys.isEmpty()) {
- if (this.isNotEmpty()) {
- append(" $midDot ")
- }
+ val (constraintChipList, constraintMode) =
+ buildConstraintChipList(
+ keyMap.constraintState,
+ constraintErrorSnapshot,
+ isEnabled = keyMap.isEnabled,
+ )
- append(getString(R.string.no_trigger))
- }
- }
+ val hasTriggerError =
+ keyMap.trigger.keys.any { triggerErrorSnapshot.getTriggerError(keyMap, it) != null }
- val triggerErrors = keyMap.trigger.keys.mapNotNull { key ->
- triggerErrorSnapshot.getTriggerError(
- keyMap,
- key,
- )
- }.distinct()
+ val hasError: Boolean =
+ hasTriggerError ||
+ actionChipList.any { it is ComposeChipModel.Error } ||
+ constraintChipList.any { it is ComposeChipModel.Error }
return KeyMapListItemModel.Content(
uid = keyMap.uid,
triggerKeys = triggerKeys,
triggerSeparatorIcon = triggerSeparator,
- triggerErrors = triggerErrors,
actions = actionChipList,
constraints = constraintChipList,
- constraintMode = keyMap.constraintState.mode,
+ constraintMode = constraintMode,
options = options,
- extraInfo = extraInfo.takeIf { it.isNotBlank() },
+ isEnabled = keyMap.isEnabled,
+ hasError = hasError,
)
}
@@ -121,18 +119,17 @@ class KeyMapListItemCreator(
): List = sequence {
val midDot = getString(R.string.middot)
- val actionErrors = errorSnapshot.getErrors(keyMap.actionList.map { it.data })
+ 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.data,
- showDeviceDescriptors,
- )
- }"
+ "${action.multiplier}x ${actionUiHelper.getTitle(action, showDeviceDescriptors)}"
} else {
- actionUiHelper.getTitle(action.data, showDeviceDescriptors)
+ actionUiHelper.getTitle(action, showDeviceDescriptors)
}
val chipText = buildString {
@@ -153,7 +150,7 @@ class KeyMapListItemCreator(
append(
getString(
- R.string.action_title_wait,
+ R.string.action_title_wait_ms,
action.delayBeforeNextAction,
),
)
@@ -163,8 +160,14 @@ class KeyMapListItemCreator(
val icon: ComposeIconInfo = actionUiHelper.getIcon(action.data)
val error: KMError? = actionErrors[action.data]
- val chip = if (error == null) {
- ComposeChipModel.Normal(id = action.uid, text = chipText, icon = icon)
+ // Disabled actions or key maps are never performed so do not show their errors.
+ val chip = if (error == null || !keyMap.isEnabled || !action.isEnabled) {
+ ComposeChipModel.Normal(
+ id = action.uid,
+ text = chipText,
+ icon = icon,
+ isEnabled = keyMap.isEnabled && action.isEnabled,
+ )
} else {
ComposeChipModel.Error(action.uid, chipText, error, isFixable = error.isFixable)
}
@@ -176,59 +179,112 @@ class KeyMapListItemCreator(
fun buildConstraintChipList(
constraintState: ConstraintState,
errorSnapshot: ConstraintErrorSnapshot,
- ): List = sequence {
- for (constraint in constraintState.constraints) {
- val text: String = constraintUiHelper.getTitle(constraint)
- val icon: ComposeIconInfo = constraintUiHelper.getIcon(constraint)
- val error: KMError? = errorSnapshot.getError(constraint)
-
- val chip: ComposeChipModel = if (error == null) {
- ComposeChipModel.Normal(
- id = constraint.uid,
- text = text,
- icon = icon,
- )
- } else {
- ComposeChipModel.Error(constraint.uid, text, error, error.isFixable)
- }
-
- yield(chip)
+ isEnabled: Boolean,
+ ): Pair, ConstraintMode> {
+ if (constraintState.groups.isEmpty()) {
+ return Pair(emptyList(), constraintState.mode)
}
- }.toList()
- private fun createExtraInfoString(
- keyMap: KeyMap,
- actionChipList: List,
- constraintChipList: List,
- ) = buildString {
- val midDot by lazy { getString(R.string.middot) }
+ if (constraintState.groups.size == 1) {
+ // If only one group then show the list of constraints as normal
+ val firstGroup = constraintState.groups[0]
+ val chips =
+ firstGroup.constraints.map { buildConstraintChip(it, errorSnapshot, isEnabled) }
- if (!keyMap.isEnabled) {
- append(getString(R.string.switch_disabled))
+ return Pair(chips, firstGroup.mode)
+ } else {
+ val chips =
+ constraintState.groups.mapNotNull {
+ buildGroupConstraintChip(
+ it,
+ errorSnapshot,
+ isEnabled,
+ )
+ }
+
+ return Pair(chips, constraintState.mode)
}
+ }
- if (actionChipList.any { it is ComposeChipModel.Error }) {
- if (this.isNotEmpty()) {
- append(" $midDot ")
- }
+ private fun buildConstraintChip(
+ constraint: Constraint,
+ errorSnapshot: ConstraintErrorSnapshot,
+ isEnabled: Boolean,
+ ): ComposeChipModel {
+ val text: String = constraintUiHelper.getTitle(constraint)
+ val icon: ComposeIconInfo = constraintUiHelper.getIcon(constraint)
+ val error: KMError? = errorSnapshot.getError(constraint)
+
+ // Constraints for disabled key maps are never checked so do not show their errors.
+ val chip: ComposeChipModel = if (error == null || !isEnabled) {
+ ComposeChipModel.Normal(
+ id = constraint.uid,
+ text = text,
+ icon = icon,
+ isEnabled = isEnabled,
+ )
+ } else {
+ ComposeChipModel.Error(
+ constraint.uid,
+ text,
+ error,
+ error.isFixable,
+ )
+ }
+ return chip
+ }
- append(getString(R.string.tap_actions_to_fix))
+ private fun buildGroupConstraintChip(
+ group: ConstraintGroup,
+ errorSnapshot: ConstraintErrorSnapshot,
+ isEnabled: Boolean,
+ ): ComposeChipModel? {
+ if (group.constraints.isEmpty()) {
+ return null
}
- if (constraintChipList.any { it is ComposeChipModel.Error }) {
- if (this.isNotEmpty()) {
- append(" $midDot ")
+ if (group.constraints.size == 1) {
+ return buildConstraintChip(group.constraints[0], errorSnapshot, isEnabled)
+ } else {
+ val text = if (group.name == null) {
+ when (group.mode) {
+ ConstraintMode.AND -> getPluralString(
+ R.plurals.constraint_group_title_and,
+ group.constraints.size,
+ group.constraints.size,
+ )
+
+ ConstraintMode.OR -> getPluralString(
+ R.plurals.constraint_group_title_or,
+ group.constraints.size,
+ group.constraints.size,
+ )
+ }
+ } else {
+ group.name
}
- append(getString(R.string.tap_constraints_to_fix))
- }
+ val icon: ComposeIconInfo? = null
+ val error: KMError? =
+ group.constraints.firstNotNullOfOrNull { errorSnapshot.getError(it) }
- if (actionChipList.isEmpty()) {
- if (this.isNotEmpty()) {
- append(" $midDot ")
+ // Constraints for disabled key maps are never checked so do not show their errors.
+ val chip: ComposeChipModel = if (error == null || !isEnabled) {
+ ComposeChipModel.Normal(
+ id = group.uid,
+ text = text,
+ icon = icon,
+ isEnabled = isEnabled,
+ )
+ } else {
+ ComposeChipModel.Error(
+ id = group.uid,
+ text = text,
+ error = error,
+ isFixable = error.isFixable,
+ )
}
-
- append(getString(R.string.no_actions))
+ return chip
}
}
@@ -263,6 +319,7 @@ class KeyMapListItemCreator(
private fun keyEventTriggerKeyName(
key: KeyEventTriggerKey,
+ allKeys: List,
showDeviceDescriptors: Boolean,
): String = buildString {
when (key.clickType) {
@@ -273,22 +330,7 @@ class KeyMapListItemCreator(
append(key.getCodeLabel(this@KeyMapListItemCreator))
- val deviceName = when (key.device) {
- is KeyEventTriggerDevice.Internal -> null
-
- is KeyEventTriggerDevice.Any -> getString(R.string.any_device)
-
- is KeyEventTriggerDevice.External -> {
- if (showDeviceDescriptors) {
- InputDeviceUtils.appendDeviceDescriptorToName(
- key.device.descriptor,
- key.device.name,
- )
- } else {
- key.device.name
- }
- }
- }
+ val deviceName = getTriggerKeyDeviceName(key, allKeys, showDeviceDescriptors)
val parts = mutableListOf()
@@ -313,6 +355,39 @@ class KeyMapListItemCreator(
}
}
+ private fun getTriggerKeyDeviceName(
+ key: KeyEventTriggerKey,
+ allKeys: List,
+ showDeviceDescriptors: Boolean,
+ ): String? {
+ val keyEventKeys = allKeys.filterIsInstance()
+
+ // If all the keys are Internal or Any, then do not show any extra text for brevity.
+ // Adding the extra information for each key is extra verbosity without any significance.
+ if (keyEventKeys.all { it.device == KeyEventTriggerDevice.Internal } ||
+ keyEventKeys.all { it.device == KeyEventTriggerDevice.Any }
+ ) {
+ return null
+ }
+
+ return when (key.device) {
+ is KeyEventTriggerDevice.Internal -> getString(R.string.this_device)
+
+ is KeyEventTriggerDevice.Any -> getString(R.string.any_device)
+
+ is KeyEventTriggerDevice.External -> {
+ if (showDeviceDescriptors) {
+ InputDeviceUtils.appendDeviceDescriptorToName(
+ key.device.descriptor,
+ key.device.name,
+ )
+ } else {
+ key.device.name
+ }
+ }
+ }
+ }
+
private fun evdevTriggerKeyName(key: EvdevTriggerKey): String = buildString {
when (key.clickType) {
ClickType.LONG_PRESS -> append(longPressString).append(" ")
@@ -387,10 +462,6 @@ class KeyMapListItemCreator(
labels.add(getString(R.string.flag_long_press_double_vibration))
}
- if (trigger.triggerFromOtherApps) {
- labels.add(getString(R.string.flag_trigger_from_other_apps))
- }
-
if (trigger.showToast) {
labels.add(getString(R.string.flag_show_toast))
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt
index 89321395b2..29f20bf363 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt
@@ -1,76 +1,43 @@
package io.github.sds100.keymapper.base.home
-import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.text.InlineTextContent
-import androidx.compose.foundation.text.appendInlineContent
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.outlined.ArrowForward
-import androidx.compose.material.icons.outlined.Add
-import androidx.compose.material.icons.outlined.FlashlightOn
-import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
-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.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.google.accompanist.drawablepainter.rememberDrawablePainter
import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.base.compose.KeyMapperTheme
-import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
-import io.github.sds100.keymapper.base.trigger.TriggerError
-import io.github.sds100.keymapper.base.utils.ui.compose.CompactChip
-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.base.utils.ui.compose.ErrorCompactChip
-import io.github.sds100.keymapper.base.utils.ui.drawable
-import io.github.sds100.keymapper.common.utils.KMError
import io.github.sds100.keymapper.common.utils.State
@Composable
@@ -84,8 +51,7 @@ fun KeyMapList(
onClickKeyMap: (String) -> Unit = {},
onLongClickKeyMap: (String) -> Unit = {},
onSelectedChange: (String, Boolean) -> Unit = { _, _ -> },
- onFixClick: (KMError) -> Unit = {},
- onTriggerErrorClick: (TriggerError) -> Unit = {},
+ onFixClick: (String) -> Unit = {},
bottomListPadding: Dp = 100.dp,
) {
val haptics = LocalHapticFeedback.current
@@ -170,8 +136,7 @@ fun KeyMapList(
onLongClickKeyMap(model.content.uid)
},
onSelectedChange = { onSelectedChange(model.content.uid, it) },
- onFixClick = onFixClick,
- onTriggerErrorClick = onTriggerErrorClick,
+ onFixClick = { onFixClick(model.content.uid) },
)
}
@@ -232,490 +197,13 @@ private fun EmptyKeyMapList(modifier: Modifier = Modifier) {
}
}
-val chipHeight = 28.dp
-
-@Composable
-private fun KeyMapListItem(
- modifier: Modifier = Modifier,
- isSelectable: Boolean,
- model: KeyMapListItemModel,
- onClickKeyMap: () -> Unit,
- onLongClickKeyMap: () -> Unit,
- onSelectedChange: (Boolean) -> Unit,
- onFixClick: (KMError) -> Unit,
- onTriggerErrorClick: (TriggerError) -> Unit,
-) {
- OutlinedCard(
- modifier = modifier,
- onClick = onClickKeyMap,
- ) {
- Row(
- modifier = Modifier.combinedClickable(
- onClick = onClickKeyMap,
- onLongClick = onLongClickKeyMap,
- ),
- ) {
- if (isSelectable) {
- CompositionLocalProvider(
- LocalMinimumInteractiveComponentSize provides 16.dp,
- ) {
- Checkbox(
- modifier = Modifier
- .padding(start = 16.dp)
- .align(Alignment.CenterVertically),
- checked = model.isSelected,
- onCheckedChange = onSelectedChange,
- )
- }
- }
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 10.dp),
- ) {
- if (model.content.extraInfo != null) {
- Row(
- modifier = Modifier.heightIn(min = chipHeight),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = model.content.extraInfo,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
-
- if (model.content.triggerKeys.isNotEmpty()) {
- Row(
- modifier = Modifier.heightIn(min = chipHeight),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- TriggerDescription(
- modifier = Modifier.fillMaxWidth(),
- triggerKeys = model.content.triggerKeys,
- separator = model.content.triggerSeparatorIcon,
- )
- }
- }
-
- if (model.content.triggerErrors.isNotEmpty()) {
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(
- 4.dp,
- alignment = Alignment.CenterVertically,
- ),
- ) {
- for (error in model.content.triggerErrors) {
- ErrorCompactChip(
- onClick = { onTriggerErrorClick(error) },
- text = getTriggerErrorMessage(error),
- enabled = error.isFixable,
- )
- }
- }
- }
-
- if (model.content.actions.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- itemVerticalAlignment = Alignment.CenterVertically,
- verticalArrangement = Arrangement.spacedBy(
- 8.dp,
- alignment = Alignment.CenterVertically,
- ),
- ) {
- Text(
- text = stringResource(R.string.action_list_header),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold,
- )
- for (chipModel in model.content.actions) {
- ActionConstraintChip(
- chipModel,
- onFixClick = onFixClick,
- )
- }
- }
- }
-
- if (model.content.constraints.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- itemVerticalAlignment = Alignment.CenterVertically,
- verticalArrangement = Arrangement.spacedBy(
- 8.dp,
- alignment = Alignment.CenterVertically,
- ),
- ) {
- Text(
- text = stringResource(R.string.constraint_list_header),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold,
- )
- for ((index, chipModel) in model.content.constraints.withIndex()) {
- ActionConstraintChip(
- chipModel,
- onFixClick = onFixClick,
- )
-
- if (index < model.content.constraints.lastIndex) {
- when (model.content.constraintMode) {
- ConstraintMode.AND -> Text(
- text = stringResource(R.string.constraint_mode_and),
- style = MaterialTheme.typography.labelMedium,
- )
-
- ConstraintMode.OR -> Text(
- text = stringResource(R.string.constraint_mode_or),
- style = MaterialTheme.typography.labelMedium,
- )
- }
- }
- }
- }
- }
-
- if (model.content.options.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
- Row(
- modifier = Modifier.heightIn(min = chipHeight),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- OptionsDescription(
- modifier = Modifier.fillMaxWidth(),
- options = model.content.options,
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun TriggerDescription(
- modifier: Modifier = Modifier,
- triggerKeys: List,
- separator: ImageVector,
-) {
- val text = buildAnnotatedString {
- pushStyle(
- MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(),
- )
- append(stringResource(R.string.trigger_header))
- pop()
- append(" ")
-
- for ((index, key) in triggerKeys.withIndex()) {
- append(key)
-
- if (index < triggerKeys.lastIndex) {
- append(" ")
- appendInlineContent("separator")
- append(" ")
- }
- }
- }
-
- val inlineContent = mapOf(
- "separator" to InlineTextContent(
- placeholder = Placeholder(
- width = 14.sp,
- height = 14.sp,
- placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
- ),
- ) {
- Icon(imageVector = separator, contentDescription = null)
- },
- )
-
- Text(
- modifier = modifier,
- text = text,
- inlineContent = inlineContent,
- style = MaterialTheme.typography.bodyMedium,
- )
-}
-
-@Composable
-private fun OptionsDescription(modifier: Modifier = Modifier, options: List) {
- val dot = stringResource(R.string.middot)
- val text = buildAnnotatedString {
- pushStyle(
- MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(),
- )
- append(stringResource(R.string.option_list_header))
- pop()
- append(" ")
-
- for ((index, option) in options.withIndex()) {
- append(option)
-
- if (index < options.lastIndex) {
- append(" $dot ")
- }
- }
- }
-
- Text(
- modifier = modifier,
- text = text,
- style = MaterialTheme.typography.bodyMedium,
- )
-}
-
-@Composable
-private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: (KMError) -> Unit) {
- when (model) {
- is ComposeChipModel.Normal -> {
- CompactChip(
- text = model.text,
- icon = model.icon?.let { icon ->
- {
- when (icon) {
- is ComposeIconInfo.Drawable -> Icon(
- modifier = Modifier.fillMaxHeight(),
- painter = rememberDrawablePainter(icon.drawable),
- contentDescription = null,
- tint = Color.Unspecified,
- )
-
- is ComposeIconInfo.Vector -> Icon(
- modifier = Modifier.fillMaxHeight(),
- imageVector = icon.imageVector,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurface,
- )
- }
- }
- },
- )
- }
-
- is ComposeChipModel.Error -> ErrorCompactChip(
- onClick = { onFixClick(model.error) },
- model.text,
- model.isFixable,
- )
- }
-}
-
-@Composable
-private fun getTriggerErrorMessage(error: TriggerError): String {
- return when (error) {
- TriggerError.DND_ACCESS_DENIED -> stringResource(R.string.trigger_error_dnd_access_denied)
-
- TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource(
- R.string.trigger_error_cant_detect_in_phone_call,
- )
-
- TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource(
- R.string.trigger_error_assistant_not_purchased,
- )
-
- TriggerError.DPAD_IME_NOT_SELECTED -> stringResource(
- R.string.trigger_error_dpad_ime_not_selected,
- )
-
- TriggerError.FLOATING_BUTTON_DELETED -> stringResource(
- R.string.trigger_error_floating_button_deleted,
- )
-
- TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource(
- R.string.trigger_error_floating_buttons_not_purchased,
- )
-
- TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource(
- R.string.trigger_error_product_verification_failed,
- )
-
- TriggerError.SYSTEM_BRIDGE_UNSUPPORTED -> stringResource(
- R.string.trigger_error_system_bridge_unsupported,
- )
-
- TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> stringResource(
- R.string.trigger_error_system_bridge_disconnected,
- )
-
- TriggerError.EVDEV_DEVICE_NOT_FOUND -> stringResource(
- R.string.trigger_error_evdev_device_not_found,
- )
-
- TriggerError.MIGRATE_SCREEN_OFF_TRIGGER -> stringResource(
- R.string.trigger_error_migrate_screen_off_key_map,
- )
- }
-}
-
-@Composable
-private fun sampleList(): List {
- val context = LocalContext.current
-
- return listOf(
- KeyMapListItemModel(
- isSelected = true,
- KeyMapListItemModel.Content(
- uid = "0",
- triggerKeys = listOf("Volume down", "Volume up", "Volume down"),
- triggerSeparatorIcon = Icons.AutoMirrored.Outlined.ArrowForward,
- actions = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Open Key Mapper",
- ),
- ComposeChipModel.Error(
- id = "1",
- text = "Input KEYCODE_0 • Repeat until released",
- error = KMError.NoCompatibleImeChosen,
- ),
- ComposeChipModel.Normal(
- id = "2",
- text = "Input KEYCODE_Q",
- icon = null,
- ),
- ComposeChipModel.Normal(
- id = "3",
- text = "Toggle flashlight",
- icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn),
- ),
- ),
- constraintMode = ConstraintMode.AND,
- constraints = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Key Mapper is not open",
- ),
- ComposeChipModel.Error(
- id = "1",
- "Key Mapper is playing media",
- error = KMError.AppNotFound(""),
- ),
- ),
- options = listOf("Vibrate"),
- triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED),
- extraInfo = "Disabled • No trigger",
- ),
- ),
- KeyMapListItemModel(
- isSelected = true,
- KeyMapListItemModel.Content(
- uid = "1",
- triggerKeys = listOf("Volume down", "Volume up"),
- triggerSeparatorIcon = Icons.Outlined.Add,
- actions = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Open Key Mapper",
- ),
- ),
- constraintMode = ConstraintMode.AND,
- constraints = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Key Mapper is not open",
- ),
- ),
- options = listOf(
- "Vibrate",
- "Vibrate when keys are initially pressed and again when long pressed",
- ),
- triggerErrors = emptyList(),
- extraInfo = null,
- ),
- ),
- KeyMapListItemModel(
- isSelected = true,
- KeyMapListItemModel.Content(
- uid = "2",
- triggerKeys = listOf("Volume down", "Volume up"),
- triggerSeparatorIcon = Icons.Outlined.Add,
- actions = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Open Key Mapper",
- ),
- ),
- constraintMode = ConstraintMode.AND,
- constraints = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Key Mapper is not open",
- ),
- ),
- options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = null,
- ),
- ),
- KeyMapListItemModel(
- isSelected = true,
- KeyMapListItemModel.Content(
- uid = "3",
- triggerKeys = listOf("Volume down", "Volume up"),
- triggerSeparatorIcon = Icons.Outlined.Add,
- actions = listOf(
- ComposeChipModel.Normal(
- id = "0",
- ComposeIconInfo.Drawable(
- drawable = context.drawable(R.drawable.ic_launcher_web),
- ),
- "Open Key Mapper",
- ),
- ),
- constraintMode = ConstraintMode.AND,
- constraints = emptyList(),
- options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = null,
- ),
- ),
- KeyMapListItemModel(
- isSelected = false,
- content = KeyMapListItemModel.Content(
- uid = "4",
- triggerKeys = emptyList(),
- triggerSeparatorIcon = Icons.Outlined.Add,
- actions = emptyList(),
- constraintMode = ConstraintMode.OR,
- constraints = emptyList(),
- options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = "Disabled • No trigger",
- ),
- ),
- )
-}
-
@Preview
@Composable
private fun ListPreview() {
KeyMapperTheme {
KeyMapList(
modifier = Modifier.fillMaxSize(),
- listItems = State.Data(sampleList()),
+ listItems = State.Data(sameKeyMapListItems()),
bottomListPadding = 100.dp,
)
}
@@ -727,7 +215,7 @@ private fun SelectableListPreview() {
KeyMapperTheme {
KeyMapList(
modifier = Modifier.fillMaxSize(),
- listItems = State.Data(sampleList()),
+ listItems = State.Data(sameKeyMapListItems()),
isSelectable = true,
bottomListPadding = 100.dp,
)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt
index 9633aca76a..759c990810 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt
@@ -11,12 +11,13 @@ import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase
import io.github.sds100.keymapper.base.backup.ExportedBackupLocation
import io.github.sds100.keymapper.base.backup.ImportExportState
import io.github.sds100.keymapper.base.backup.RestoreType
+import io.github.sds100.keymapper.base.constraints.Constraint
import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot
import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper
-import io.github.sds100.keymapper.base.groups.Group
import io.github.sds100.keymapper.base.groups.GroupFamily
import io.github.sds100.keymapper.base.groups.GroupListItemModel
+import io.github.sds100.keymapper.base.groups.GroupWithState
import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase
import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget
@@ -29,6 +30,7 @@ import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
import io.github.sds100.keymapper.base.trigger.TriggerError
import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot
import io.github.sds100.keymapper.base.utils.getFullMessage
+import io.github.sds100.keymapper.base.utils.isFixable
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
@@ -143,6 +145,50 @@ class KeyMapListViewModel(
),
)
+ private val fixErrorsKeyMapUid = MutableStateFlow(null)
+
+ val fixErrorsDialogState: StateFlow =
+ fixErrorsKeyMapUid.flatMapLatest { dialogId ->
+ if (dialogId == null) {
+ flowOf(null)
+ } else {
+ combine(
+ keyMapGroupStateFlow,
+ listKeyMaps.triggerErrorSnapshot,
+ listKeyMaps.actionErrorSnapshot,
+ listKeyMaps.constraintErrorSnapshot,
+ ) { keyMapGroup, triggerSnapshot, actionSnapshot, constraintSnapshot ->
+
+ val errors: List = when (dialogId) {
+ is FixErrorsDialogId.Group -> {
+ buildGroupConstraintsErrors(
+ keyMapGroup.group?.constraintState?.allConstraints.orEmpty(),
+ constraintSnapshot,
+ )
+ }
+
+ is FixErrorsDialogId.KeyMap -> {
+ val keyMap = keyMapGroup.keyMaps.dataOrNull()
+ ?.firstOrNull { it.uid == dialogId.uid }
+
+ keyMap
+ ?.let {
+ buildKeyMapErrors(
+ it,
+ triggerSnapshot,
+ actionSnapshot,
+ constraintSnapshot,
+ )
+ }
+ .orEmpty()
+ }
+ }
+
+ errors.takeIf { it.isNotEmpty() }?.let { FixErrorsDialogState(it) }
+ }
+ }
+ }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), null)
+
private val _importExportState = MutableStateFlow(ImportExportState.Idle)
val importExportState: StateFlow = _importExportState.asStateFlow()
@@ -446,15 +492,18 @@ class KeyMapListViewModel(
val selectedKeyMapsEnabled: SelectedKeyMapsEnabled? =
getKeyMapSelectedState(keyMapGroup.keyMaps.dataOrNull() ?: emptyList())
+ val (constraints, constraintMode) = listItemCreator.buildConstraintChipList(
+ keyMapGroup.group.constraintState,
+ constraintErrorSnapshot,
+ isEnabled = true,
+ )
+
return KeyMapAppBarState.ChildGroup(
groupName = keyMapGroup.group.name,
- constraints = listItemCreator.buildConstraintChipList(
- keyMapGroup.group.constraintState,
- constraintErrorSnapshot,
- ),
- constraintMode = keyMapGroup.group.constraintState.mode,
+ constraints = constraints,
+ constraintMode = constraintMode,
parentConstraintCount = keyMapGroup.parents.sumOf {
- it.constraintState.constraints.size
+ it.constraintState.allConstraints.size
},
subGroups = subGroupListItems,
breadcrumbs = breadcrumbs,
@@ -465,18 +514,20 @@ class KeyMapListViewModel(
}
}
- private fun buildGroupListItem(group: Group): GroupListItemModel {
+ private fun buildGroupListItem(groupState: GroupWithState): GroupListItemModel {
var icon: ComposeIconInfo? = null
- val constraint = group.constraintState.constraints.firstOrNull()
+ val constraint = groupState.group.constraintState.allConstraints.firstOrNull()
if (constraint != null) {
icon = constraintUiHelper.getIcon(constraint)
}
return GroupListItemModel(
- uid = group.uid,
- name = group.name,
+ uid = groupState.group.uid,
+ name = groupState.group.name,
icon = icon,
+ isEnabled = groupState.isEnabled,
+ isError = groupState.isError,
)
}
@@ -500,6 +551,42 @@ class KeyMapListViewModel(
}
}
+ private fun buildKeyMapErrors(
+ keyMap: KeyMap,
+ triggerSnapshot: TriggerErrorSnapshot,
+ actionSnapshot: ActionErrorSnapshot,
+ constraintSnapshot: ConstraintErrorSnapshot,
+ ): List {
+ val triggerErrors = keyMap.trigger.keys
+ .mapNotNull { triggerSnapshot.getTriggerError(keyMap, it) }
+ .distinct()
+ .map { KeyMapError.Trigger(it, it.getFullMessage(this)) }
+
+ val actionErrorsByData = actionSnapshot.getErrors(keyMap.actionList.map { it.data })
+ val actionErrors = keyMap.actionList.filter { it.isEnabled }
+ .mapNotNull { actionErrorsByData[it.data] }
+ .map { KeyMapError.Action(it, it.getFullMessage(this), it.isFixable) }
+
+ val constraintErrors = keyMap.constraintState.groups.flatMap { group ->
+ group.constraints.mapNotNull { constraint ->
+ constraintSnapshot.getError(constraint)
+ }
+ }.map { KeyMapError.Constraint(it, it.getFullMessage(this), it.isFixable) }
+
+ return (triggerErrors + actionErrors + constraintErrors).distinct()
+ }
+
+ private fun buildGroupConstraintsErrors(
+ constraints: List,
+ constraintSnapshot: ConstraintErrorSnapshot,
+ ): List {
+ val constraintErrors = constraints.mapNotNull { constraint ->
+ constraintSnapshot.getError(constraint)
+ }.map { KeyMapError.Constraint(it, it.getFullMessage(this), it.isFixable) }
+
+ return constraintErrors.distinct()
+ }
+
fun onKeyMapCardClick(uid: String) {
if (multiSelectProvider.state.value is SelectionState.Selecting) {
multiSelectProvider.toggleSelection(uid)
@@ -573,6 +660,38 @@ class KeyMapListViewModel(
}
}
+ fun onFixClick(keyMapUid: String) {
+ fixErrorsKeyMapUid.value = FixErrorsDialogId.KeyMap(keyMapUid)
+ }
+
+ fun onFixGroupConstraintsClick() {
+ val groupUid = keyMapGroupStateFlow.value.group?.uid
+
+ if (groupUid != null) {
+ fixErrorsKeyMapUid.value = FixErrorsDialogId.Group(groupUid)
+ }
+ }
+
+ fun onDismissFixErrorsDialog() {
+ fixErrorsKeyMapUid.value = null
+ }
+
+ fun onFixErrorClick(item: KeyMapError) {
+ when (item) {
+ is KeyMapError.Trigger -> onFixTriggerError(item.error)
+ is KeyMapError.Action -> fixKeyMapError(item.error)
+ is KeyMapError.Constraint -> fixKeyMapError(item.error)
+ }
+ }
+
+ private fun fixKeyMapError(error: KMError) {
+ if (error is KMError.KeyEventActionError) {
+ showFixKeyEventActionBottomSheet()
+ } else {
+ coroutineScope.launch { listKeyMaps.fixError(error) }
+ }
+ }
+
fun onFixClick(error: KMError) {
coroutineScope.launch {
when (error) {
@@ -911,6 +1030,12 @@ class KeyMapListViewModel(
}
}
+ fun onNotGroupConstraintClick(uid: String) {
+ coroutineScope.launch {
+ listKeyMaps.toggleGroupConstraintNot(uid)
+ }
+ }
+
fun onGroupConstraintModeChanged(mode: ConstraintMode) {
coroutineScope.launch {
listKeyMaps.setGroupConstraintMode(mode)
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 351dc92631..da03180b3f 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
@@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.home
import android.database.sqlite.SQLiteConstraintException
import io.github.sds100.keymapper.base.R
+import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot
import io.github.sds100.keymapper.base.backup.BackupManager
import io.github.sds100.keymapper.base.backup.BackupManagerImpl
import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase
@@ -10,11 +11,13 @@ import io.github.sds100.keymapper.base.backup.ExportedBackupLocation
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.ConstraintErrorSnapshot
import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper
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.keymaps.DisplayKeyMapUseCase
import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.base.keymaps.KeyMapEntityMapper
@@ -34,13 +37,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.channelFlow
-import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
@@ -85,24 +86,80 @@ class ListKeyMapsUseCaseImpl @Inject constructor(
}
}
- override val keyMapGroup: Flow = channelFlow {
- keyMapListGroupUid
- .flatMapLatest(::getGroupFamily)
- .map { groupFamily ->
- val parentGroups = getParentsRecursively(groupFamily.group?.uid)
+ override val keyMapGroup: Flow = keyMapListGroupUid
+ .flatMapLatest(::getGroupFamily)
+ .flatMapLatest { groupFamily ->
+ val parentGroups = getParentsRecursively(groupFamily.group?.uid)
+ getSubGroupsWithState(groupFamily.children).map { subGroups ->
KeyMapGroup(
group = groupFamily.group,
- subGroups = groupFamily.children,
+ subGroups = subGroups,
keyMaps = State.Loading,
parents = parentGroups,
)
- }.onEach { send(it) }
- .flatMapLatest { keyMapGroup ->
- getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) }
- }.collect {
- send(it)
}
+ }
+ .flatMapLatest { keyMapGroup ->
+ getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) }
+ }
+
+ /**
+ * Builds the enabled/error state of each subgroup from its own direct key maps and
+ * constraints only, not recursively through nested subgroups.
+ */
+ private fun getSubGroupsWithState(groups: List): Flow> {
+ if (groups.isEmpty()) {
+ return flowOf(emptyList())
+ }
+
+ return combine(
+ groups.map { group ->
+ combine(
+ getKeyMapsByGroup(group.uid),
+ actionErrorSnapshot,
+ constraintErrorSnapshot,
+ ) { keyMapsState, actionSnapshot, constraintSnapshot ->
+ buildGroupWithState(
+ group,
+ keyMapsState.dataOrNull() ?: emptyList(),
+ actionSnapshot,
+ constraintSnapshot,
+ )
+ }
+ },
+ ) { it.toList() }
+ }
+
+ private fun buildGroupWithState(
+ group: Group,
+ keyMaps: List,
+ actionErrorSnapshot: ActionErrorSnapshot,
+ constraintErrorSnapshot: ConstraintErrorSnapshot,
+ ): GroupWithState {
+ val isEnabled = keyMaps.isEmpty() || keyMaps.any { it.isEnabled }
+
+ val hasOwnConstraintError = group.constraintState.allConstraints.any {
+ constraintErrorSnapshot.getError(it) != null
+ }
+
+ val hasEnabledKeyMapError = keyMaps.any { keyMap ->
+ keyMap.isEnabled &&
+ (
+ actionErrorSnapshot.getErrors(
+ keyMap.actionList.filter { it.isEnabled }.map { it.data },
+ ).values.any { it != null } ||
+ keyMap.constraintState.allConstraints.any {
+ constraintErrorSnapshot.getError(it) != null
+ }
+ )
+ }
+
+ return GroupWithState(
+ group = group,
+ isEnabled = isEnabled,
+ isError = hasOwnConstraintError || hasEnabledKeyMapError,
+ )
}
override val selectionGroupFamily: Flow =
@@ -289,12 +346,46 @@ class ListKeyMapsUseCaseImpl @Inject constructor(
val groupEntity = groupRepository.getGroup(groupUid) ?: return
var group = GroupEntityMapper.fromEntity(groupEntity)
- val constraints = group.constraintState.constraints
- .filterNot { it.uid == constraintUid }
- .toSet()
+ val constraintGroups = group.constraintState.groups
+ .map { constraintGroup ->
+ constraintGroup.copy(
+ constraints = constraintGroup.constraints.filterNot {
+ it.uid == constraintUid
+ },
+ )
+ }
+ .filter { it.constraints.isNotEmpty() }
group =
- group.copy(constraintState = group.constraintState.copy(constraints = constraints))
+ group.copy(constraintState = group.constraintState.copy(groups = constraintGroups))
+
+ try {
+ groupRepository.update(GroupEntityMapper.toEntity(group))
+ } catch (_: SQLiteConstraintException) {
+ return
+ }
+ }
+ }
+
+ override suspend fun toggleGroupConstraintNot(constraintUid: String) {
+ keyMapListGroupUid.value?.also { groupUid ->
+ val groupEntity = groupRepository.getGroup(groupUid) ?: return
+ var group = GroupEntityMapper.fromEntity(groupEntity)
+
+ val constraintGroups = group.constraintState.groups.map { constraintGroup ->
+ constraintGroup.copy(
+ constraints = constraintGroup.constraints.map { constraint ->
+ if (constraint.uid == constraintUid) {
+ constraint.copy(isNot = !constraint.isNot)
+ } else {
+ constraint
+ }
+ },
+ )
+ }
+
+ group =
+ group.copy(constraintState = group.constraintState.copy(groups = constraintGroups))
try {
groupRepository.update(GroupEntityMapper.toEntity(group))
@@ -324,20 +415,18 @@ class ListKeyMapsUseCaseImpl @Inject constructor(
}
}
- private fun getKeyMapsByGroup(groupUid: String?): Flow>> = channelFlow {
- send(State.Loading)
-
- combine(
+ private fun getKeyMapsByGroup(groupUid: String?): Flow>> {
+ return combine(
keyMapRepository.getByGroup(groupUid),
floatingButtonRepository.buttonsList,
) { keyMapList, buttonListState ->
- Pair(keyMapList, buttonListState)
- }.collectLatest { (keyMapList, buttonListState) ->
- if (buttonListState is State.Loading) {
- send(State.Loading)
- }
+ val buttonList = when (buttonListState) {
+ is State.Loading -> {
+ return@combine State.Loading
+ }
- val buttonList = buttonListState.dataOrNull() ?: return@collectLatest
+ is State.Data -> buttonListState.data
+ }
val keyMaps = withContext(Dispatchers.Default) {
keyMapList.map { keyMap ->
@@ -345,7 +434,7 @@ class ListKeyMapsUseCaseImpl @Inject constructor(
}
}
- send(State.Data(keyMaps))
+ State.Data(keyMaps)
}
}
@@ -390,6 +479,7 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase {
suspend fun renameGroup(name: String): Boolean
suspend fun addGroupConstraint(constraintData: ConstraintData)
suspend fun removeGroupConstraint(constraintUid: String)
+ suspend fun toggleGroupConstraintNot(constraintUid: String)
suspend fun setGroupConstraintMode(mode: ConstraintMode)
fun getGroups(parentUid: String?): Flow>
fun enableGroupKeyMaps()
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
new file mode 100644
index 0000000000..756cc3e2fd
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt
@@ -0,0 +1,273 @@
+package io.github.sds100.keymapper.base.home
+
+import android.content.res.Configuration
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+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.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.AutoAwesome
+import androidx.compose.material.icons.rounded.BugReport
+import androidx.compose.material.icons.rounded.CleaningServices
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+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.onboarding.WhatsNewItem
+import io.github.sds100.keymapper.base.onboarding.WhatsNewNotes
+import io.github.sds100.keymapper.base.onboarding.WhatsNewState
+import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog
+import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow
+import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcon
+import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons
+import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe
+
+@Composable
+fun WhatsNewDialog(state: WhatsNewState, onDismissRequest: () -> Unit) {
+ CustomDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(stringResource(R.string.pos_ok))
+ }
+ },
+ dismissButton = {},
+ ) { WhatsNewDialogContent(state = state) }
+}
+
+/**
+ * The scrollable body of the What's New dialog. The [footer] is shown after the changelog link.
+ */
+@Composable
+fun WhatsNewDialogContent(
+ modifier: Modifier = Modifier,
+ state: WhatsNewState,
+ footer: (@Composable ColumnScope.() -> Unit)? = null,
+) {
+ val ctx = LocalContext.current
+ val uriHandler = LocalUriHandler.current
+ val changelogUrl = stringResource(R.string.url_changelog)
+
+ Column {
+ Spacer(Modifier.height(16.dp))
+
+ Column(
+ modifier = modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ WhatsNewHeader(
+ modifier = Modifier.padding(horizontal = 24.dp),
+ versionName = state.versionName,
+ )
+
+// state.notes.spotlight?.let {
+// WhatsNewSpotlightCard(
+// modifier = Modifier.padding(horizontal = 24.dp),
+// item = it,
+// )
+// }
+
+ WhatsNewSection(
+ title = stringResource(R.string.whats_new_section_new),
+ icon = Icons.Rounded.AutoAwesome,
+ items = state.notes.new,
+ )
+
+ WhatsNewSection(
+ title = stringResource(R.string.whats_new_section_improved),
+ icon = Icons.Rounded.CleaningServices,
+ items = state.notes.improved,
+ )
+
+ WhatsNewSection(
+ title = stringResource(R.string.whats_new_section_fixed),
+ icon = Icons.Rounded.BugReport,
+ items = state.notes.fixed,
+ )
+
+ TextButton(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .align(Alignment.Start),
+ onClick = { uriHandler.openUriSafe(ctx, changelogUrl) },
+ ) {
+ Text(stringResource(R.string.whats_new_view_changelog_button))
+ }
+ }
+
+ if (footer != null) {
+ footer()
+ }
+ }
+}
+
+@Composable
+private fun WhatsNewHeader(modifier: Modifier = Modifier, versionName: String) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Image(
+ modifier = Modifier.size(48.dp),
+ imageVector = KeyMapperIcons.KeyMapperIcon,
+ contentDescription = null,
+ )
+
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = stringResource(R.string.whats_new),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp),
+ text = stringResource(R.string.whats_new_version_badge, versionName),
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun WhatsNewSection(
+ modifier: Modifier = Modifier,
+ title: String,
+ icon: ImageVector,
+ items: List,
+) {
+ if (items.isEmpty()) {
+ return
+ }
+
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OptionsHeaderRow(
+ modifier = Modifier.padding(horizontal = 24.dp),
+ icon = icon,
+ text = title,
+ )
+
+ for (item in items) {
+ if (item.isSpotlight) {
+ Surface(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ border = BorderStroke(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.3f),
+ ),
+ shape = MaterialTheme.shapes.small,
+ ) {
+ WhatsNewItemText(Modifier.padding(vertical = 8.dp, horizontal = 12.dp), item)
+ }
+ } else {
+ WhatsNewItemText(
+ modifier = Modifier.padding(horizontal = 24.dp),
+ item,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun WhatsNewItemText(modifier: Modifier = Modifier, item: WhatsNewItem) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Column {
+ Text(text = item.title, style = MaterialTheme.typography.bodyMedium)
+
+ if (item.description != null) {
+ Text(
+ text = item.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+private val previewState = WhatsNewState(
+ versionName = "4.4.0",
+ notes = WhatsNewNotes(
+ new = listOf(
+ WhatsNewItem(
+ title = "Cycle keyboard language",
+ description = "Switch between your keyboard's languages with a key press",
+ isSpotlight = true,
+ ),
+ WhatsNewItem("Sort key maps by enabled/disabled", null),
+ WhatsNewItem("Export/import key maps on Android TV", null),
+ ),
+ improved = listOf(
+ WhatsNewItem("Screen tap/swipe/pinch actions now scale to display resolution", null),
+ ),
+ fixed = listOf(
+ WhatsNewItem("Fix some crashes when auto starting Expert Mode", null),
+ WhatsNewItem("Fix shell commands failing with Windows line endings", null),
+ ),
+ ),
+)
+
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewWhatsNewDialog() {
+ KeyMapperTheme {
+ WhatsNewDialog(state = previewState, onDismissRequest = {})
+ }
+}
+
+@Preview(showSystemUi = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun PreviewWhatsNewDialogDark() {
+ KeyMapperTheme {
+ WhatsNewDialog(state = previewState, onDismissRequest = {})
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewWhatsNewDialogNoSpotlight() {
+ KeyMapperTheme {
+ WhatsNewDialog(
+ state = previewState.copy(
+ notes = WhatsNewNotes(
+ new = emptyList(),
+ improved = emptyList(),
+ fixed = listOf(WhatsNewItem("Many bug fixes and improvements", null)),
+ ),
+ ),
+ onDismissRequest = {},
+ )
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt
index b51cb76ec8..6a55f17cb7 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt
@@ -2,7 +2,9 @@ package io.github.sds100.keymapper.base.keymaps
import android.graphics.Color
import android.graphics.drawable.Drawable
-import io.github.sds100.keymapper.base.R
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import io.github.sds100.keymapper.base.actions.ActionUiHelper
import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase
import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase
@@ -11,11 +13,11 @@ 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
import io.github.sds100.keymapper.base.utils.ui.TintType
-import io.github.sds100.keymapper.base.utils.ui.showDialog
+import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.utils.State
import io.github.sds100.keymapper.common.utils.dataOrNull
import io.github.sds100.keymapper.common.utils.mapData
@@ -40,13 +42,18 @@ class ConfigKeyMapOptionsViewModel(
private val dialogProvider: DialogProvider,
navigationProvider: NavigationProvider,
resourceProvider: ResourceProvider,
+ vibrateConfigDelegate: VibrateConfigDelegate,
) : ResourceProvider by resourceProvider,
DialogProvider by dialogProvider,
NavigationProvider by navigationProvider,
+ VibrateConfigDelegate by vibrateConfigDelegate,
KeyMapOptionsCallback {
private val actionUiHelper = ActionUiHelper(displayUseCase, resourceProvider)
+ var createShortcutDialogState: CreateShortcutDialogState? by mutableStateOf(null)
+ private set
+
val state: StateFlow> = combine(
config.keyMap,
systemBridgeConnectionManager.connectionState,
@@ -66,8 +73,15 @@ class ConfigKeyMapOptionsViewModel(
config.setSequenceTriggerTimeout(timeout)
}
- override fun onVibrateDurationChanged(duration: Int) {
- config.setVibrationDuration(duration)
+ override fun onVibrateSettingsClick() {
+ val currentState = state.value.dataOrNull() ?: return
+ openVibrateConfig(currentState.vibrateEffect, currentState.defaultVibrateDuration)
+ }
+
+ fun onDoneVibrateConfigClick() {
+ val effect = buildVibrateEffect() ?: return
+ closeVibrateConfig()
+ config.setVibrateEffect(effect)
}
override fun onVibrateChanged(checked: Boolean) {
@@ -82,22 +96,31 @@ class ConfigKeyMapOptionsViewModel(
config.setShowToastEnabled(checked)
}
- override fun onTriggerFromOtherAppsChanged(checked: Boolean) {
- config.setTriggerFromOtherAppsEnabled(checked)
- }
-
override fun onOpenExpertModeSettings() {
coroutineScope.launch {
navigate("screen_off_trigger_tip", NavDestination.ExpertMode)
}
}
+ override fun onTriggerByIntentClick() {
+ coroutineScope.launch {
+ val keyMapUid = config.keyMap.firstOrNull()?.dataOrNull()?.uid ?: return@launch
+ navigate("trigger_by_intent", NavDestination.TriggerByIntent(keyMapUid))
+ }
+ }
+
+ override fun onEnableByIntentClick() {
+ coroutineScope.launch {
+ val keyMapUid = config.keyMap.firstOrNull()?.dataOrNull()?.uid ?: return@launch
+ navigate("enable_by_intent", NavDestination.EnableByIntent(keyMapUid))
+ }
+ }
+
override fun onCreateShortcutClick() {
coroutineScope.launch {
val mapping = config.keyMap.firstOrNull()?.dataOrNull() ?: return@launch
val keyMapUid = mapping.uid
- val key = "create_launcher_shortcut"
val defaultShortcutName: String
val icon: Drawable?
@@ -131,25 +154,34 @@ class ConfigKeyMapOptionsViewModel(
icon = null
}
- val shortcutName = showDialog(
- key,
- DialogModel.Text(
- getString(R.string.hint_shortcut_name),
- allowEmpty = false,
- text = defaultShortcutName,
- ),
- ) ?: return@launch
+ createShortcutDialogState = CreateShortcutDialogState(
+ keyMapUid = keyMapUid,
+ defaultName = defaultShortcutName,
+ icon = icon,
+ )
+ }
+ }
- val result = createKeyMapShortcut.pinShortcut(keyMapUid, shortcutName, icon)
+ suspend fun onConfirmCreateShortcut(name: String): String? {
+ val dialogState = createShortcutDialogState ?: return null
- result.onFailure { error ->
- val snackBar = DialogModel.SnackBar(
- message = error.getFullMessage(this@ConfigKeyMapOptionsViewModel),
- )
+ val result = createKeyMapShortcut.pinShortcut(dialogState.keyMapUid, name, dialogState.icon)
- showDialog("create_shortcut_result", snackBar)
- }
+ var errorMessage: String? = null
+
+ result.onFailure { error ->
+ errorMessage = error.getFullMessage(this@ConfigKeyMapOptionsViewModel)
+ }
+
+ if (errorMessage == null) {
+ createShortcutDialogState = null
}
+
+ return errorMessage
+ }
+
+ fun onDismissCreateShortcutDialog() {
+ createShortcutDialogState = null
}
private suspend fun buildState(
@@ -175,8 +207,8 @@ class ConfigKeyMapOptionsViewModel(
?: defaultSequenceTriggerTimeout,
defaultSequenceTriggerTimeout = defaultSequenceTriggerTimeout,
- showVibrateDuration = keyMap.trigger.isChangingVibrationDurationAllowed(),
- vibrateDuration = keyMap.trigger.vibrateDuration ?: defaultVibrateDuration,
+ showVibrateSettings = keyMap.trigger.isChangingVibrationDurationAllowed(),
+ vibrateEffect = keyMap.trigger.vibrateEffect,
defaultVibrateDuration = defaultVibrateDuration,
showVibrate = keyMap.trigger.isVibrateAllowed(),
@@ -185,7 +217,6 @@ class ConfigKeyMapOptionsViewModel(
showLongPressDoubleVibration = keyMap.trigger.isLongPressDoubleVibrationAllowed(),
longPressDoubleVibration = keyMap.trigger.longPressDoubleVibration,
- triggerFromOtherApps = keyMap.trigger.triggerFromOtherApps,
keyMapUid = keyMap.uid,
isLauncherShortcutButtonEnabled = createKeyMapShortcut.isSupported,
@@ -197,6 +228,12 @@ class ConfigKeyMapOptionsViewModel(
}
}
+data class CreateShortcutDialogState(
+ val keyMapUid: String,
+ val defaultName: String,
+ val icon: Drawable?,
+)
+
data class KeyMapOptionsState(
val showLongPressDelay: Boolean,
val longPressDelay: Int,
@@ -210,8 +247,8 @@ data class KeyMapOptionsState(
val sequenceTriggerTimeout: Int,
val defaultSequenceTriggerTimeout: Int,
- val showVibrateDuration: Boolean,
- val vibrateDuration: Int,
+ val showVibrateSettings: Boolean,
+ val vibrateEffect: VibrateEffect?,
val defaultVibrateDuration: Int,
val showVibrate: Boolean,
@@ -220,7 +257,6 @@ data class KeyMapOptionsState(
val showLongPressDoubleVibration: Boolean,
val longPressDoubleVibration: Boolean,
- val triggerFromOtherApps: Boolean,
val keyMapUid: String,
val isLauncherShortcutButtonEnabled: Boolean,
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 d9f3ddf70c..c2509d11f1 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
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@@ -115,8 +116,8 @@ class ConfigKeyMapStateImpl @Inject constructor(
}
}
- override fun update(block: (keyMap: KeyMap) -> KeyMap) {
- _keyMap.update { value -> value.mapData { block.invoke(it) } }
+ override fun update(block: (keyMap: KeyMap) -> KeyMap): State {
+ return _keyMap.updateAndGet { value -> value.mapData { block.invoke(it) } }
}
}
@@ -124,7 +125,7 @@ interface ConfigKeyMapState {
val keyMap: StateFlow>
val isEdited: Boolean
- fun update(block: (keyMap: KeyMap) -> KeyMap)
+ fun update(block: (keyMap: KeyMap) -> KeyMap): State
fun save()
suspend fun loadKeyMap(uid: String)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt
index 653d709d02..7729d0fe56 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt
@@ -10,6 +10,7 @@ import io.github.sds100.keymapper.base.input.EvdevDevicesDelegate
import io.github.sds100.keymapper.base.purchasing.PurchasingError.ProductNotPurchased
import io.github.sds100.keymapper.base.purchasing.PurchasingManager
import io.github.sds100.keymapper.base.purchasing.RevenueCatEntitlementId
+import io.github.sds100.keymapper.base.purchasing.RevenueCatState
import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper
import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface
import io.github.sds100.keymapper.base.trigger.TriggerError
@@ -88,11 +89,11 @@ class DisplayKeyMapUseCaseImpl @Inject constructor(
* This waits for the purchases to be processed with a timeout so the UI doesn't
* say there are no purchases while it is loading.
*/
- private val purchasesFlow: Flow>>> = callbackFlow {
+ private val purchasesFlow: Flow>> = callbackFlow {
try {
val value = withTimeout(5000L) {
- purchasingManager.entitlements.filterIsInstance<
- State.Data>>,
+ purchasingManager.state.filterIsInstance<
+ State.Data>,
>()
.first()
}
@@ -101,7 +102,7 @@ class DisplayKeyMapUseCaseImpl @Inject constructor(
} catch (_: TimeoutCancellationException) {
}
- purchasingManager.entitlements.collect(this::send)
+ purchasingManager.state.collect(this::send)
}
private val systemBridgeConnectionState: Flow =
@@ -128,7 +129,8 @@ class DisplayKeyMapUseCaseImpl @Inject constructor(
isKeyMapperImeChosen = keyMapperImeHelper.isCompatibleImeChosen(),
isDndAccessGranted = permissionAdapter.isGranted(Permission.ACCESS_NOTIFICATION_POLICY),
isRootGranted = permissionAdapter.isGranted(Permission.ROOT),
- purchases = purchases.dataOrNull() ?: Success(emptySet()),
+ purchases = purchases.dataOrNull()?.then { Success(it.entitlements) }
+ ?: Success(emptySet()),
showDpadImeSetupError = showDpadImeSetupError,
isSystemBridgeConnected = sysBridgeState is SystemBridgeConnectionState.Connected,
evdevDevices = evdevDevices,
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
new file mode 100644
index 0000000000..137be1786f
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt
@@ -0,0 +1,117 @@
+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.R
+import io.github.sds100.keymapper.base.compose.KeyMapperTheme
+import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock
+
+// Duplicated from the :api module because :api depends on :base and not the other
+// way around. CreateKeyMapShortcutUseCase duplicates them in the same way.
+private const val ACTION_ENABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_ENABLE_KEY_MAP"
+private const val ACTION_DISABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_DISABLE_KEY_MAP"
+private const val ACTION_TOGGLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_TOGGLE_KEY_MAP"
+private const val ENABLE_RECEIVER_CLASS =
+ "io.github.sds100.keymapper.api.EnableKeyMapsBroadcastReceiver"
+
+@Composable
+fun EnableByIntentScreen(
+ modifier: Modifier = Modifier,
+ keyMapUid: String,
+ onBackClick: () -> Unit,
+) {
+ val packageName = LocalContext.current.packageName
+ val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label)
+
+ IntentScreen(
+ modifier = modifier,
+ title = stringResource(R.string.key_map_options_enable_by_intent_title),
+ text = stringResource(R.string.intent_screen_enable_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 = ENABLE_RECEIVER_CLASS,
+ clipboardLabel = clipboardLabel,
+ )
+
+ 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 = ACTION_ENABLE_KEY_MAP,
+ clipboardLabel = clipboardLabel,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_action_disable),
+ code = ACTION_DISABLE_KEY_MAP,
+ clipboardLabel = clipboardLabel,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_action_toggle),
+ code = ACTION_TOGGLE_KEY_MAP,
+ clipboardLabel = clipboardLabel,
+ )
+
+ Text(
+ text = stringResource(R.string.intent_screen_extras_explanation),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_extra_name),
+ code = EXTRA_KEYMAP_UID,
+ clipboardLabel = clipboardLabel,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_extra_value),
+ code = keyMapUid,
+ clipboardLabel = clipboardLabel,
+ )
+
+ AdbSection(
+ command = buildAdbCommand(
+ packageName = packageName,
+ receiverClass = ENABLE_RECEIVER_CLASS,
+ action = ACTION_TOGGLE_KEY_MAP,
+ keyMapUid = keyMapUid,
+ ),
+ )
+ }
+}
+
+@Preview(heightDp = 1200)
+@Composable
+private fun EnableByIntentScreenPreview() {
+ KeyMapperTheme {
+ EnableByIntentScreen(
+ keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23",
+ onBackClick = {},
+ )
+ }
+}
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
new file mode 100644
index 0000000000..6df94ecb41
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt
@@ -0,0 +1,127 @@
+package io.github.sds100.keymapper.base.keymaps
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.displayCutoutPadding
+import androidx.compose.foundation.layout.fillMaxSize
+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.automirrored.rounded.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.github.sds100.keymapper.base.R
+import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock
+
+// Duplicated from the :api module because :api depends on :base and not the other
+// way around. CreateKeyMapShortcutUseCase duplicates them in the same way.
+internal const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID"
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun IntentScreen(
+ modifier: Modifier = Modifier,
+ title: String,
+ text: String,
+ onBackClick: () -> Unit,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier.displayCutoutPadding(),
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.action_go_back),
+ )
+ }
+ },
+ )
+ },
+ ) { innerPadding ->
+ val layoutDirection = LocalLayoutDirection.current
+ val startPadding = innerPadding.calculateStartPadding(layoutDirection)
+ val endPadding = innerPadding.calculateEndPadding(layoutDirection)
+
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ top = innerPadding.calculateTopPadding(),
+ bottom = innerPadding.calculateBottomPadding(),
+ start = startPadding,
+ end = endPadding,
+ ),
+ ) {
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+internal fun AdbSection(modifier: Modifier = Modifier, command: String) {
+ val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label)
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.intent_screen_adb_title),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Text(
+ text = stringResource(R.string.intent_screen_adb_explanation),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_command),
+ code = command,
+ clipboardLabel = clipboardLabel,
+ )
+ }
+}
+
+internal fun buildAdbCommand(
+ packageName: String,
+ receiverClass: String,
+ action: String,
+ keyMapUid: String,
+): String {
+ return "adb shell am broadcast -n $packageName/$receiverClass " +
+ "-a $action --es $EXTRA_KEYMAP_UID $keyMapUid"
+}
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 f75bbf55c6..33e30c37dc 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
@@ -5,13 +5,15 @@ import io.github.sds100.keymapper.base.actions.Action
import io.github.sds100.keymapper.base.actions.ActionData
import io.github.sds100.keymapper.base.actions.ActionEntityMapper
import io.github.sds100.keymapper.base.actions.canBeHeldDown
-import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper
-import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper
import io.github.sds100.keymapper.base.constraints.ConstraintState
+import io.github.sds100.keymapper.base.constraints.ConstraintStateEntityMapper
import io.github.sds100.keymapper.base.detection.KeyMapAlgorithm
+import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey
+import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey
import io.github.sds100.keymapper.base.trigger.Trigger
import io.github.sds100.keymapper.base.trigger.TriggerEntityMapper
import io.github.sds100.keymapper.base.trigger.TriggerKey
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout
import io.github.sds100.keymapper.data.entities.KeyMapEntity
import java.util.UUID
@@ -34,8 +36,8 @@ data class KeyMap(
val vibrate: Boolean
get() = trigger.vibrate
- val vibrateDuration: Int?
- get() = trigger.vibrateDuration
+ val vibrateEffect: VibrateEffect?
+ get() = trigger.vibrateEffect
fun isRepeatingActionsAllowed(): Boolean = KeyMapAlgorithm.performActionOnDown(trigger)
@@ -57,6 +59,13 @@ data class KeyMap(
fun isChangingRepeatLimitAllowed(action: Action): Boolean =
action.repeat && isRepeatingActionsAllowed()
+ /**
+ * The release of assistant and fingerprint gesture triggers can not be detected.
+ */
+ fun isRepeatUntilReleasedAllowed(): Boolean = trigger.keys.none {
+ it is AssistantTriggerKey || it is FingerprintTriggerKey
+ }
+
fun isStopHoldingDownActionWhenTriggerPressedAgainAllowed(action: Action): Boolean =
action.holdDown && !action.repeat && isHoldingDownActionAllowed(action)
@@ -116,17 +125,18 @@ object KeyMapEntityMapper {
.filterNotNull()
.mapNotNull { ActionEntityMapper.fromEntity(it) }
- val constraintList =
- entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet()
-
- val constraintMode = ConstraintModeEntityMapper.fromEntity(entity.constraintMode)
+ val constraintState = ConstraintStateEntityMapper.fromEntityWithGroups(
+ entity.constraintList,
+ entity.constraintGroups,
+ entity.constraintMode,
+ )
return KeyMap(
dbId = entity.id,
uid = entity.uid,
trigger = TriggerEntityMapper.fromEntity(entity.trigger, floatingButtons),
actionList = actionList,
- constraintState = ConstraintState(constraintList, constraintMode),
+ constraintState = constraintState,
isEnabled = entity.isEnabled,
groupUid = entity.groupUid,
)
@@ -134,17 +144,16 @@ object KeyMapEntityMapper {
fun toEntity(keyMap: KeyMap, dbId: Long): KeyMapEntity {
val actionEntityList = ActionEntityMapper.toEntity(keyMap)
+ val (constraintList, constraintGroups, constraintMode) =
+ ConstraintStateEntityMapper.toEntityWithGroups(keyMap.constraintState)
return KeyMapEntity(
id = dbId,
trigger = TriggerEntityMapper.toEntity(keyMap.trigger),
actionList = actionEntityList,
- constraintList = keyMap.constraintState.constraints.map {
- ConstraintEntityMapper.toEntity(
- it,
- )
- },
- constraintMode = ConstraintModeEntityMapper.toEntity(keyMap.constraintState.mode),
+ constraintList = constraintList,
+ constraintGroups = constraintGroups,
+ constraintMode = constraintMode,
isEnabled = keyMap.isEnabled,
uid = keyMap.uid,
groupUid = keyMap.groupUid,
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 e2c7e887f0..b1bf1f5e7e 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
@@ -1,8 +1,6 @@
package io.github.sds100.keymapper.base.keymaps
import android.content.ClipData
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,14 +14,20 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddHome
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Message
+import androidx.compose.material.icons.outlined.RocketLaunch
+import androidx.compose.material.icons.outlined.Route
+import androidx.compose.material.icons.outlined.Sensors
+import androidx.compose.material.icons.outlined.Timer
+import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.rounded.ContentCopy
-import androidx.compose.material3.Checkbox
+import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -31,13 +35,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -47,9 +48,14 @@ import io.github.sds100.keymapper.base.onboarding.TipCard
import io.github.sds100.keymapper.base.utils.ui.SliderMaximums
import io.github.sds100.keymapper.base.utils.ui.SliderMinimums
import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes
-import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText
+import io.github.sds100.keymapper.base.utils.ui.compose.OptionPageButton
+import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow
import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText
-import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe
+import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose
+import io.github.sds100.keymapper.base.utils.ui.compose.SwitchSettingsPreferenceCompose
+import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog
+import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons
+import io.github.sds100.keymapper.base.utils.ui.compose.icons.MobileSensorHi
import io.github.sds100.keymapper.common.utils.State
import kotlinx.coroutines.launch
@@ -62,6 +68,20 @@ fun KeyMapOptionsScreen(modifier: Modifier = Modifier, viewModel: ConfigKeyMapOp
state = state,
callback = viewModel,
)
+
+ val createShortcutDialogState = viewModel.createShortcutDialogState
+
+ if (createShortcutDialogState != null) {
+ TextFieldDialog(
+ title = stringResource(R.string.key_map_options_create_shortcut_title),
+ submitButtonText = stringResource(R.string.pos_save),
+ initialText = createShortcutDialogState.defaultName,
+ onSubmitClick = { newText -> viewModel.onConfirmCreateShortcut(newText) },
+ onDismissRequest = viewModel::onDismissCreateShortcutDialog,
+ )
+ }
+
+ TriggerVibrationBottomSheet(viewModel)
}
@Composable
@@ -98,16 +118,15 @@ private fun Loaded(
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
- .fillMaxWidth(),
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Spacer(modifier = Modifier.height(8.dp))
if (state.showScreenOffTip) {
TipCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
+ modifier = Modifier.fillMaxWidth(),
title = stringResource(R.string.tip_screen_off_trigger_title),
message = stringResource(R.string.tip_screen_off_trigger_message),
isDismissable = false,
@@ -120,72 +139,77 @@ private fun Loaded(
)
}
- TriggerFromOtherAppsSection(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
- isChecked = state.triggerFromOtherApps,
- onCheckedChange = callback::onTriggerFromOtherAppsChanged,
- isLauncherShortcutEnabled = state.isLauncherShortcutButtonEnabled,
- keyMapUid = state.keyMapUid,
- onCreateShortcutClick = callback::onCreateShortcutClick,
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OptionsHeaderRow(
+ icon = Icons.Outlined.Info,
+ text = stringResource(R.string.key_map_options_header_information),
)
- CheckBoxText(
+ KeyMapUidRow(
modifier = Modifier
- .padding(horizontal = 8.dp)
.fillMaxWidth(),
- text = stringResource(R.string.flag_show_toast),
- isChecked = state.showToast,
- onCheckedChange = callback::onShowToastChanged,
+ keyMapUid = state.keyMapUid,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ OptionsHeaderRow(
+ icon = Icons.Outlined.Sensors,
+ text = stringResource(R.string.key_map_options_header_feedback),
)
if (state.showVibrate) {
- CheckBoxText(
- modifier = Modifier
- .padding(horizontal = 8.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.flag_vibrate),
+ SwitchSettingsPreferenceCompose(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.flag_vibrate),
+ text = stringResource(R.string.key_map_options_vibrate_summary),
+ icon = Icons.Outlined.Vibration,
isChecked = state.vibrate,
onCheckedChange = callback::onVibrateChanged,
- )
- }
-
- if (state.showVibrateDuration) {
- val vibrateDurationMin = SliderMinimums.VIBRATION_DURATION
- val vibrateDurationMax = SliderMaximums.VIBRATION_DURATION
- SliderOptionText(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- title = stringResource(R.string.extra_label_vibration_duration),
- defaultValue = state.defaultVibrateDuration.toFloat(),
- value = state.vibrateDuration.toFloat(),
- valueText = { "${it.toInt()} ms" },
- onValueChange = { callback.onVibrateDurationChanged(it.toInt()) },
- valueRange = vibrateDurationMin.toFloat()..vibrateDurationMax.toFloat(),
- stepSize = SliderStepSizes.VIBRATION_DURATION,
+ // Always allow users to play around with the vibration settings.
+ isSettingsEnabled = true,
+ onSettingsClick = callback::onVibrateSettingsClick,
)
}
if (state.showLongPressDoubleVibration) {
- CheckBoxText(
- modifier = Modifier
- .padding(horizontal = 8.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.flag_long_press_double_vibration),
+ SwitchPreferenceCompose(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.flag_long_press_double_vibration),
+ text = null,
+ icon = KeyMapperIcons.MobileSensorHi,
isChecked = state.longPressDoubleVibration,
onCheckedChange = callback::onLongPressDoubleVibrationChanged,
)
}
+ SwitchPreferenceCompose(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.flag_show_toast),
+ text = stringResource(R.string.key_map_options_show_toast_summary),
+ icon = Icons.Outlined.Message,
+ isChecked = state.showToast,
+ onCheckedChange = callback::onShowToastChanged,
+ )
+
+ val showTimingSection = state.showLongPressDelay ||
+ state.showDoublePressDelay ||
+ state.showSequenceTriggerTimeout
+
+ if (showTimingSection) {
+ OptionsHeaderRow(
+ icon = Icons.Outlined.Timer,
+ text = stringResource(R.string.key_map_options_header_timing),
+ )
+ }
+
if (state.showLongPressDelay) {
val longPressDelayMin = SliderMinimums.TRIGGER_LONG_PRESS_DELAY
val longPressDelayMax = SliderMaximums.TRIGGER_LONG_PRESS_DELAY
SliderOptionText(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ .fillMaxWidth(),
title = stringResource(R.string.extra_label_long_press_delay_timeout),
defaultValue = state.defaultLongPressDelay.toFloat(),
value = state.longPressDelay.toFloat(),
@@ -201,8 +225,7 @@ private fun Loaded(
val doublePressDelayMax = SliderMaximums.TRIGGER_DOUBLE_PRESS_DELAY
SliderOptionText(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ .fillMaxWidth(),
title = stringResource(R.string.extra_label_double_press_delay_timeout),
defaultValue = state.defaultDoublePressDelay.toFloat(),
value = state.doublePressDelay.toFloat(),
@@ -220,8 +243,7 @@ private fun Loaded(
SliderMaximums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT.toFloat()
SliderOptionText(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ .fillMaxWidth(),
title = stringResource(R.string.extra_label_sequence_trigger_timeout),
defaultValue = state.defaultSequenceTriggerTimeout.toFloat(),
value = state.sequenceTriggerTimeout.toFloat(),
@@ -232,129 +254,84 @@ private fun Loaded(
)
}
+ OptionsHeaderRow(
+ icon = Icons.Outlined.Route,
+ text = stringResource(R.string.key_map_options_header_integration),
+ )
+
+ OptionPageButton(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.key_map_options_enable_by_intent_title),
+ text = stringResource(R.string.key_map_options_intent_summary),
+ icon = Icons.Rounded.PowerSettingsNew,
+ onClick = callback::onEnableByIntentClick,
+ )
+ OptionPageButton(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.key_map_options_trigger_by_intent_title),
+ text = stringResource(R.string.key_map_options_intent_summary),
+ icon = Icons.Outlined.RocketLaunch,
+ onClick = callback::onTriggerByIntentClick,
+ )
+
+ OptionPageButton(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.key_map_options_create_shortcut_title),
+ text = if (state.isLauncherShortcutButtonEnabled) {
+ stringResource(R.string.key_map_options_create_shortcut_summary)
+ } else {
+ stringResource(R.string.key_map_options_create_shortcut_summary_unsupported)
+ },
+ icon = Icons.Outlined.AddHome,
+ enabled = state.isLauncherShortcutButtonEnabled,
+ onClick = callback::onCreateShortcutClick,
+ )
+
Spacer(Modifier.height(8.dp))
}
}
@Composable
-private fun TriggerFromOtherAppsSection(
- modifier: Modifier = Modifier,
- isChecked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- onCreateShortcutClick: () -> Unit,
- isLauncherShortcutEnabled: Boolean,
- keyMapUid: String,
-) {
- Column(modifier) {
- Row {
- Surface(
- modifier = Modifier.weight(1f),
- shape = MaterialTheme.shapes.medium,
- color = Color.Transparent,
- ) {
- Row(
- modifier = Modifier
- .clickable { onCheckedChange(!isChecked) }
- .padding(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Checkbox(
- checked = isChecked,
- // This is null so tapping on the checkbox highlights the whole row.
- onCheckedChange = null,
- )
-
- Text(
- modifier = Modifier.padding(horizontal = 12.dp),
+private fun KeyMapUidRow(modifier: Modifier = Modifier, keyMapUid: String) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.key_map_options_key_map_id),
+ style = MaterialTheme.typography.bodyLarge,
+ )
- text = stringResource(R.string.flag_trigger_from_other_apps),
- style = MaterialTheme.typography.bodyLarge,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
+ Text(
+ text = keyMapUid,
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontFamily = FontFamily.Monospace,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
- AnimatedVisibility(isChecked) {
- Column {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .weight(1f),
- text = keyMapUid,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.labelLarge,
- )
-
- val clipboard = LocalClipboard.current
- val scope = rememberCoroutineScope()
- val clipboardLabel = stringResource(R.string.clipboard_label_keymap_uid)
-
- IconButton(
- modifier = Modifier
- .padding(horizontal = 8.dp)
- .size(36.dp),
- onClick = {
- scope.launch {
- clipboard.setClipEntry(
- ClipEntry(ClipData.newPlainText(clipboardLabel, keyMapUid)),
- )
- }
- },
- ) {
- Icon(
- imageVector = Icons.Rounded.ContentCopy,
- contentDescription = stringResource(
- R.string.flag_trigger_from_other_apps_copy_uid,
- ),
- )
- }
- }
-
- Spacer(Modifier.height(8.dp))
-
- OutlinedButton(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- onClick = onCreateShortcutClick,
- enabled = isLauncherShortcutEnabled,
- ) {
- val text = if (isLauncherShortcutEnabled) {
- stringResource(R.string.button_create_keymap_shortcut_in_launcher_enabled)
- } else {
- stringResource(R.string.button_create_keymap_shortcut_in_launcher_disabled)
- }
-
- Text(text = text)
- }
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ val clipboardLabel = stringResource(R.string.clipboard_label_keymap_uid)
- val uriHandler = LocalUriHandler.current
- val ctx = LocalContext.current
- val intentGuideUrl = stringResource(R.string.url_trigger_by_intent_guide)
-
- FilledTonalButton(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- onClick = {
- uriHandler.openUriSafe(ctx, intentGuideUrl)
- },
- ) {
- Text(
- text = stringResource(
- R.string.button_open_trigger_keymap_from_intent_guide,
- ),
+ IconButton(
+ modifier = Modifier.size(36.dp),
+ onClick = {
+ scope.launch {
+ clipboard.setClipEntry(
+ ClipEntry(ClipData.newPlainText(clipboardLabel, keyMapUid)),
)
}
- }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ContentCopy,
+ contentDescription = stringResource(
+ R.string.flag_trigger_from_other_apps_copy_uid,
+ ),
+ )
}
}
}
@@ -363,16 +340,17 @@ interface KeyMapOptionsCallback {
fun onLongPressDelayChanged(delay: Int) = run { }
fun onDoublePressDelayChanged(delay: Int) = run { }
fun onSequenceTriggerTimeoutChanged(timeout: Int) = run { }
- fun onVibrateDurationChanged(duration: Int) = run { }
+ fun onVibrateSettingsClick() = run { }
fun onVibrateChanged(checked: Boolean) = run { }
fun onLongPressDoubleVibrationChanged(checked: Boolean) = run { }
fun onShowToastChanged(checked: Boolean) = run { }
- fun onTriggerFromOtherAppsChanged(checked: Boolean) = run {}
fun onCreateShortcutClick() = run { }
fun onOpenExpertModeSettings() = run {}
+ fun onTriggerByIntentClick() = run {}
+ fun onEnableByIntentClick() = run {}
}
-@Preview
+@Preview(heightDp = 1300)
@Composable
private fun Preview() {
KeyMapperTheme {
@@ -392,8 +370,8 @@ private fun Preview() {
sequenceTriggerTimeout = 1000,
defaultSequenceTriggerTimeout = 1000,
- showVibrateDuration = true,
- vibrateDuration = 100,
+ showVibrateSettings = true,
+ vibrateEffect = null,
defaultVibrateDuration = 100,
showVibrate = true,
@@ -402,8 +380,7 @@ private fun Preview() {
showLongPressDoubleVibration = true,
longPressDoubleVibration = false,
- triggerFromOtherApps = true,
- keyMapUid = "00000-00000-00000-0000000000000000000000000000000000",
+ keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23",
isLauncherShortcutButtonEnabled = false,
showToast = true,
@@ -416,3 +393,47 @@ private fun Preview() {
}
}
}
+
+@Preview
+@Composable
+private fun PreviewEvdevTrigger() {
+ KeyMapperTheme {
+ Surface {
+ KeyMapOptionsScreen(
+ state = State.Data(
+ KeyMapOptionsState(
+ showLongPressDelay = false,
+ longPressDelay = 300,
+ defaultLongPressDelay = 400,
+
+ showDoublePressDelay = false,
+ doublePressDelay = 100,
+ defaultDoublePressDelay = 100,
+
+ showSequenceTriggerTimeout = false,
+ sequenceTriggerTimeout = 1000,
+ defaultSequenceTriggerTimeout = 1000,
+
+ showVibrateSettings = false,
+ vibrateEffect = null,
+ defaultVibrateDuration = 100,
+
+ showVibrate = true,
+ vibrate = false,
+
+ showLongPressDoubleVibration = false,
+ longPressDoubleVibration = false,
+
+ keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23",
+ isLauncherShortcutButtonEnabled = true,
+
+ showToast = false,
+ showScreenOffTip = false,
+ isExpertModeStarted = true,
+ ),
+ ),
+ callback = object : KeyMapOptionsCallback {},
+ )
+ }
+ }
+}
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
new file mode 100644
index 0000000000..0fd3092c8a
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt
@@ -0,0 +1,87 @@
+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 TriggerByIntentScreen(
+ modifier: Modifier = Modifier,
+ keyMapUid: String,
+ onBackClick: () -> Unit,
+) {
+ // Use the package name of this build so the .debug and .ci builds show the correct one.
+ val packageName = LocalContext.current.packageName
+ val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label)
+
+ IntentScreen(
+ modifier = modifier,
+ title = stringResource(R.string.key_map_options_trigger_by_intent_title),
+ text = stringResource(R.string.intent_screen_trigger_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.TRIGGER_RECEIVER_CLASS,
+ clipboardLabel = clipboardLabel,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_action),
+ code = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID,
+ clipboardLabel = clipboardLabel,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_extra_name),
+ code = EXTRA_KEYMAP_UID,
+ clipboardLabel = clipboardLabel,
+ )
+
+ CodeBlock(
+ label = stringResource(R.string.intent_screen_label_extra_value),
+ code = keyMapUid,
+ clipboardLabel = clipboardLabel,
+ )
+
+ AdbSection(
+ command = buildAdbCommand(
+ packageName = packageName,
+ receiverClass = IntentApi.TRIGGER_RECEIVER_CLASS,
+ action = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID,
+ keyMapUid = keyMapUid,
+ ),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun TriggerByIntentScreenPreview() {
+ KeyMapperTheme {
+ TriggerByIntentScreen(
+ keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23",
+ onBackClick = {},
+ )
+ }
+}
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
new file mode 100644
index 0000000000..67a554b854
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt
@@ -0,0 +1,156 @@
+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.VibrationEffectMode
+import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectPickerContent
+import io.github.sds100.keymapper.base.vibration.VibrateConfigState
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TriggerVibrationBottomSheet(viewModel: ConfigKeyMapOptionsViewModel) {
+ val scope = rememberCoroutineScope()
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ val state = viewModel.vibrateConfigState
+
+ if (state != null) {
+ TriggerVibrationBottomSheet(
+ sheetState = sheetState,
+ state = state,
+ onDismissRequest = viewModel::closeVibrateConfig,
+ onModeChange = viewModel::onVibrateModeChange,
+ onDurationChange = viewModel::onVibrateDurationChange,
+ onPredefinedTypeChange = viewModel::onVibratePredefinedTypeChange,
+ onDoneClick = {
+ scope.launch {
+ sheetState.hide()
+ viewModel.onDoneVibrateConfigClick()
+ }
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TriggerVibrationBottomSheet(
+ sheetState: SheetState,
+ state: VibrateConfigState,
+ onDismissRequest: () -> Unit = {},
+ onModeChange: (VibrationEffectMode) -> Unit = {},
+ onDurationChange: (Int) -> Unit = {},
+ onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit = {},
+ onDoneClick: () -> Unit = {},
+) {
+ val scope = rememberCoroutineScope()
+
+ 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.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))
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview(showSystemUi = true)
+@Composable
+private fun TriggerVibrationBottomSheetPreview() {
+ KeyMapperTheme {
+ val sheetState = SheetState(
+ skipPartiallyExpanded = true,
+ positionalThreshold = { 0f },
+ velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
+ )
+
+ TriggerVibrationBottomSheet(
+ sheetState = sheetState,
+ state = VibrateConfigState(durationMs = 200, defaultDurationMs = 200),
+ )
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt
index d29de0b78f..3d233ec090 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt
@@ -9,6 +9,7 @@ import io.github.sds100.keymapper.data.Keys
import io.github.sds100.keymapper.data.repositories.KeyMapRepository
import io.github.sds100.keymapper.data.repositories.PreferenceRepository
import io.github.sds100.keymapper.data.utils.PrefDelegate
+import io.github.sds100.keymapper.system.apps.PackageManagerAdapter
import io.github.sds100.keymapper.system.files.FileAdapter
import io.github.sds100.keymapper.system.permissions.Permission
import io.github.sds100.keymapper.system.permissions.PermissionAdapter
@@ -27,6 +28,7 @@ class OnboardingUseCaseImpl @Inject constructor(
private val shizukuAdapter: ShizukuAdapter,
private val permissionAdapter: PermissionAdapter,
private val keyMapRepository: KeyMapRepository,
+ private val packageManager: PackageManagerAdapter,
private val buildConfigProvider: BuildConfigProvider,
) : PreferenceRepository by settingsRepository,
OnboardingUseCase {
@@ -40,10 +42,18 @@ class OnboardingUseCaseImpl @Inject constructor(
set(Keys.lastInstalledVersionCodeHomeScreen, buildConfigProvider.versionCode)
}
- override fun getWhatsNewText(): String =
- with(fileAdapter.openAsset("whats-new.txt").bufferedReader()) {
- readText()
- }
+ override fun isFreshInstall(): Boolean {
+ return packageManager.getInstallTime(buildConfigProvider.packageName) ==
+ packageManager.getLastUpdateTime(buildConfigProvider.packageName)
+ }
+
+ override val appVersionName: String
+ get() = buildConfigProvider.version
+
+ override fun getWhatsNewNotes(): WhatsNewNotes {
+ val text = fileAdapter.openAsset("whats-new-app.txt").bufferedReader().use { it.readText() }
+ return WhatsNewParser.parse(text)
+ }
override val promptForShizukuPermission: Flow = combine(
settingsRepository.get(Keys.shownShizukuPermissionPrompt),
@@ -51,10 +61,10 @@ class OnboardingUseCaseImpl @Inject constructor(
permissionAdapter.isGrantedFlow(Permission.SHIZUKU),
) {
shownPromptBefore,
- isShizkuInstalled,
+ isShizukuInstalled,
isShizukuPermissionGranted,
->
- shownPromptBefore != true && isShizkuInstalled && !isShizukuPermissionGranted
+ shownPromptBefore != true && isShizukuInstalled && !isShizukuPermissionGranted
}
override val showShizukuAppIntroSlide: Boolean
@@ -119,7 +129,11 @@ interface OnboardingUseCase {
val showWhatsNew: Flow
fun showedWhatsNew()
- fun getWhatsNewText(): String
+ val appVersionName: String
+
+ fun isFreshInstall(): Boolean
+
+ fun getWhatsNewNotes(): WhatsNewNotes
val promptForShizukuPermission: Flow
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt
index a893facaeb..afe456f26a 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt
@@ -192,7 +192,7 @@ private fun RestrictedSettingText(modifier: Modifier = Modifier) {
)
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun EnableAccessibilityServiceDialogPreview() {
KeyMapperTheme {
@@ -204,7 +204,7 @@ private fun EnableAccessibilityServiceDialogPreview() {
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun EnableAccessibilityServiceDialogRestrictedPreview() {
KeyMapperTheme {
@@ -216,7 +216,7 @@ private fun EnableAccessibilityServiceDialogRestrictedPreview() {
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun RestartAccessibilityServiceDialogPreview() {
KeyMapperTheme {
@@ -227,7 +227,7 @@ private fun RestartAccessibilityServiceDialogPreview() {
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun CantFindAccessibilitySettingsDialogPreview() {
KeyMapperTheme {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/WhatsNewNotes.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/WhatsNewNotes.kt
new file mode 100644
index 0000000000..b237057960
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/WhatsNewNotes.kt
@@ -0,0 +1,66 @@
+package io.github.sds100.keymapper.base.onboarding
+
+data class WhatsNewState(val versionName: String, val notes: WhatsNewNotes)
+
+data class WhatsNewNotes(
+ val new: List,
+ val improved: List,
+ val fixed: List,
+)
+
+data class WhatsNewItem(
+ val title: String,
+ val description: String?,
+ val isSpotlight: Boolean = false,
+)
+
+/**
+ * Parses the in-app release notes in whats-new-app.txt. Each line starts with a prefix:
+ * "!" spotlight, "+" new, "*" improved, "-" fixed. Text after the first "|" is a description.
+ * Lines with an unknown prefix are treated as improvements.
+ */
+object WhatsNewParser {
+ fun parse(text: String): WhatsNewNotes {
+ val new = mutableListOf()
+ val improved = mutableListOf()
+ val fixed = mutableListOf()
+
+ for (rawLine in text.lines()) {
+ val line = rawLine.trim()
+
+ if (line.isEmpty()) {
+ continue
+ }
+
+ val prefix = line.first()
+ val hasKnownPrefix = prefix in setOf('!', '+', '*', '-')
+ val content = if (hasKnownPrefix) line.drop(1) else line.trimStart('•')
+ val item = parseItem(content) ?: continue
+
+ when (prefix) {
+ '!' -> new.add(item.copy(isSpotlight = true))
+ '+' -> new.add(item)
+ '-' -> fixed.add(item)
+ else -> improved.add(item)
+ }
+ }
+
+ return WhatsNewNotes(new, improved, fixed)
+ }
+
+ private fun parseItem(content: String): WhatsNewItem? {
+ val title = content.substringBefore('|').trim()
+
+ if (title.isEmpty()) {
+ return null
+ }
+
+ val description = if (content.contains('|')) {
+ content.substringAfter('|').trim().ifEmpty { null }
+ } else {
+ null
+ }
+
+ return WhatsNewItem(title, description)
+ }
+}
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 9dcc4534cb..c916a43477 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
@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
interface PurchasingManager {
val onCompleteProductPurchase: MutableSharedFlow
- val entitlements: Flow>>>
+ val state: Flow>>
suspend fun launchPurchasingFlow(
packageId: String,
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/purchasing/RevenueCatState.kt b/base/src/main/java/io/github/sds100/keymapper/base/purchasing/RevenueCatState.kt
new file mode 100644
index 0000000000..4e05c2107f
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/purchasing/RevenueCatState.kt
@@ -0,0 +1,6 @@
+package io.github.sds100.keymapper.base.purchasing
+
+data class RevenueCatState(
+ val entitlements: Set,
+ val offeringMetadata: Map,
+)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt
index 57dce3fb48..37c8f4b83e 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt
@@ -46,7 +46,6 @@ import io.github.sds100.keymapper.base.home.KeyMapAppBarState
import io.github.sds100.keymapper.base.home.KeyMapList
import io.github.sds100.keymapper.base.home.KeyMapListState
import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
-import io.github.sds100.keymapper.base.trigger.TriggerError
import io.github.sds100.keymapper.base.utils.ui.UnsavedChangesDialog
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel
import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo
@@ -308,8 +307,8 @@ private fun keyMapSampleList(): List {
),
),
options = listOf("Vibrate"),
- triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED),
- extraInfo = null,
+ isEnabled = false,
+ hasError = true,
),
),
KeyMapListItemModel(
@@ -322,8 +321,8 @@ private fun keyMapSampleList(): List {
constraintMode = ConstraintMode.OR,
constraints = emptyList(),
options = emptyList(),
- triggerErrors = emptyList(),
- extraInfo = "Disabled • No trigger",
+ isEnabled = true,
+ hasError = false,
),
),
)
@@ -438,7 +437,7 @@ private fun PreviewEmpty() {
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun ShortcutNameDialogPreview() {
KeyMapperTheme {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt
index 53a8734296..f5be0de7d3 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt
@@ -4,6 +4,7 @@ import android.content.Intent
import android.graphics.drawable.Drawable
import androidx.core.os.bundleOf
import dagger.hilt.android.scopes.ViewModelScoped
+import io.github.sds100.keymapper.base.IntentApi
import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.common.utils.KMResult
import io.github.sds100.keymapper.system.apps.AppShortcutAdapter
@@ -14,12 +15,6 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor(
private val appShortcutAdapter: AppShortcutAdapter,
) : CreateKeyMapShortcutUseCase {
- companion object {
- private const val ACTION_TRIGGER_KEYMAP_BY_UID =
- "io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID"
- private const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID"
- }
-
override val isSupported: Boolean
get() = appShortcutAdapter.areLauncherShortcutsSupported
@@ -32,15 +27,15 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor(
appShortcutAdapter.createLauncherShortcut(
iconResId = R.mipmap.ic_launcher_round,
label = shortcutLabel,
- intentAction = ACTION_TRIGGER_KEYMAP_BY_UID,
- bundleOf(EXTRA_KEYMAP_UID to keyMapUid),
+ intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID,
+ bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid),
)
} else {
appShortcutAdapter.createLauncherShortcut(
icon = icon,
label = shortcutLabel,
- intentAction = ACTION_TRIGGER_KEYMAP_BY_UID,
- bundleOf(EXTRA_KEYMAP_UID to keyMapUid),
+ intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID,
+ bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid),
)
}
return appShortcutAdapter.pinShortcut(shortcut)
@@ -51,15 +46,15 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor(
appShortcutAdapter.createLauncherShortcut(
iconResId = R.mipmap.ic_launcher_round,
label = shortcutLabel,
- intentAction = ACTION_TRIGGER_KEYMAP_BY_UID,
- bundleOf(EXTRA_KEYMAP_UID to keyMapUid),
+ intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID,
+ bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid),
)
} else {
appShortcutAdapter.createLauncherShortcut(
icon = icon,
label = shortcutLabel,
- intentAction = ACTION_TRIGGER_KEYMAP_BY_UID,
- bundleOf(EXTRA_KEYMAP_UID to keyMapUid),
+ intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID,
+ bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid),
)
}
return appShortcutAdapter.createShortcutResultIntent(shortcut)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt
index 0c692779c2..28f8d85367 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt
@@ -21,7 +21,6 @@ import io.github.sds100.keymapper.base.home.KeyMapListItemCreator
import io.github.sds100.keymapper.base.home.KeyMapListState
import io.github.sds100.keymapper.base.home.ListKeyMapsUseCase
import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState
-import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase
import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel
import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
@@ -43,7 +42,6 @@ import kotlinx.coroutines.launch
@HiltViewModel
class CreateKeyMapShortcutViewModel @Inject constructor(
private val configKeyMapState: ConfigKeyMapState,
- private val configTrigger: ConfigTriggerUseCase,
private val listKeyMaps: ListKeyMapsUseCase,
private val createKeyMapShortcut: CreateKeyMapShortcutUseCase,
private val resourceProvider: ResourceProvider,
@@ -120,10 +118,10 @@ class CreateKeyMapShortcutViewModel @Inject constructor(
}
}
- val subGroupListItems = keyMapGroup.subGroups.map { group ->
+ val subGroupListItems = keyMapGroup.subGroups.map { (group, _, _) ->
var icon: ComposeIconInfo? = null
- val constraint = group.constraintState.constraints.firstOrNull()
+ val constraint = group.constraintState.allConstraints.firstOrNull()
if (constraint != null) {
icon = constraintUiHelper.getIcon(constraint)
}
@@ -159,7 +157,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor(
isEditingGroupName = false,
isNewGroup = false,
parentConstraintCount = keyMapGroup.parents.sumOf {
- it.constraintState.constraints.size
+ it.constraintState.allConstraints.size
},
keyMapsEnabled = null,
)
@@ -175,7 +173,6 @@ class CreateKeyMapShortcutViewModel @Inject constructor(
if (state.keyMaps !is State.Data) return@launch
configKeyMapState.loadKeyMap(uid)
- configTrigger.setTriggerFromOtherAppsEnabled(true)
val keyMapState = configKeyMapState.keyMap.first()
@@ -204,7 +201,9 @@ class CreateKeyMapShortcutViewModel @Inject constructor(
// background is white. Also, getting the colorOnSurface attribute
// from the application context doesn't seem to work correctly.
TintType.OnSurface -> iconInfo.drawable.setTint(Color.BLACK)
+
is TintType.Color -> iconInfo.drawable.setTint(iconInfo.tintType.color)
+
else -> {}
}
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 fa3e7d9a7a..c095902c40 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
@@ -286,8 +286,10 @@ private fun SortDraggableList(
onSortFieldClick: (SortField) -> Unit,
) {
val lazyListState = rememberLazyListState()
+ val sortFields = remember(sortFieldOrderList) { sortFieldOrderList.map { it.field } }
val dragDropState = rememberDragDropState(
lazyListState = lazyListState,
+ keys = sortFields,
onMove = onMove,
)
@@ -296,13 +298,13 @@ private fun SortDraggableList(
state = lazyListState,
) {
itemsIndexed(
- items = sortFieldOrderList,
+ items = dragDropState.ordered(sortFieldOrderList) { it.field },
key = { _, item -> item.field },
) { index, item ->
DraggableItem(
dragDropState = dragDropState,
- index = index,
+ key = item.field,
) { isDragging ->
SortFieldListItem(
index = index + 1,
@@ -311,17 +313,7 @@ private fun SortDraggableList(
onToggle = { onSortFieldClick(item.field) },
isDragging = isDragging,
onDrag = { dragDropState.onDrag(it) },
- onDragStarted = { offset ->
- // Calculate the offset of the item in the list
- val lazyItem = lazyListState.layoutInfo.visibleItemsInfo
- .firstOrNull { it.index == index } ?: return@SortFieldListItem
-
- val initialOffset = lazyItem.offset
-
- val finalOffset = offset + Offset(0f, initialOffset.toFloat())
-
- dragDropState.onDragStart(finalOffset)
- },
+ onDragStarted = { dragDropState.onDragStart(item.field) },
onDragStopped = { dragDropState.onDragInterrupted() },
)
}
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 21cfdde574..2d7218b399 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
@@ -24,14 +24,14 @@ class KeyMapConstraintsComparator(
return 0
}
- val keyMapConstraintsLength = keyMap.constraintState.constraints.size
- val otherKeyMapConstraintsLength = otherKeyMap.constraintState.constraints.size
+ val keyMapConstraintsLength = keyMap.constraintState.allConstraints.size
+ val otherKeyMapConstraintsLength = otherKeyMap.constraintState.allConstraints.size
val maxLength = keyMapConstraintsLength.coerceAtMost(otherKeyMapConstraintsLength)
// Compare constraints one by one
for (i in 0 until maxLength) {
- val constraint = keyMap.constraintState.constraints.elementAt(i)
- val otherConstraint = otherKeyMap.constraintState.constraints.elementAt(i)
+ val constraint = keyMap.constraintState.allConstraints.elementAt(i)
+ val otherConstraint = otherKeyMap.constraintState.allConstraints.elementAt(i)
val result = compareConstraints(constraint, otherConstraint)
@@ -85,55 +85,63 @@ class KeyMapConstraintsComparator(
is ConstraintData.AppInForeground -> displayConstraints.getAppName(
constraint.data.packageName,
)
- is ConstraintData.AppNotInForeground -> displayConstraints.getAppName(
- constraint.data.packageName,
- )
- is ConstraintData.AppNotPlayingMedia -> displayConstraints.getAppName(
- constraint.data.packageName,
- )
+
is ConstraintData.AppPlayingMedia -> displayConstraints.getAppName(
constraint.data.packageName,
)
+
is ConstraintData.BtDeviceConnected -> Success(constraint.data.deviceName)
- is ConstraintData.BtDeviceDisconnected -> Success(constraint.data.deviceName)
+
is ConstraintData.Charging -> Success("")
+
is ConstraintData.DeviceIsLocked -> Success("")
- is ConstraintData.DeviceIsUnlocked -> Success("")
- is ConstraintData.Discharging -> Success("")
- is ConstraintData.FlashlightOff -> Success(constraint.data.lens.toString())
+
is ConstraintData.FlashlightOn -> Success(constraint.data.lens.toString())
+
is ConstraintData.ImeChosen -> Success(constraint.data.imeLabel)
- is ConstraintData.ImeNotChosen -> Success(constraint.data.imeLabel)
+
is ConstraintData.InPhoneCall -> Success("")
+
is ConstraintData.MediaPlaying -> Success("")
- is ConstraintData.NoMediaPlaying -> Success("")
+
+ is ConstraintData.NotificationPosted -> Success(
+ when (constraint.data) {
+ is ConstraintData.NotificationPosted.FromApp ->
+ displayConstraints.getAppName(constraint.data.packageName)
+ .valueOrNull() ?: constraint.data.packageName
+
+ is ConstraintData.NotificationPosted.TextMatch -> constraint.data.text
+ },
+ )
+
is ConstraintData.NotInPhoneCall -> Success("")
+
is ConstraintData.OrientationCustom -> Success(constraint.data.orientation.toString())
+
is ConstraintData.OrientationLandscape -> Success("")
+
is ConstraintData.OrientationPortrait -> Success("")
+
is ConstraintData.PhoneRinging -> Success("")
+
is ConstraintData.RingerMode -> Success("")
- is ConstraintData.ScreenOff -> Success("")
+
is ConstraintData.ScreenOn -> Success("")
+
is ConstraintData.DisplayResolution -> Success(
"${constraint.data.width}x${constraint.data.height}",
)
- is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) {
- Success("")
- } else {
- Success(constraint.data.ssid)
- }
- is ConstraintData.WifiDisconnected -> if (constraint.data.ssid == null) {
+ is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) {
Success("")
} else {
Success(constraint.data.ssid)
}
- is ConstraintData.WifiOff -> Success("")
is ConstraintData.WifiOn -> Success("")
- is ConstraintData.LockScreenNotShowing -> Success("")
+
is ConstraintData.LockScreenShowing -> Success("")
+
is ConstraintData.Time -> Success(
constraint.data.startTime
.toEpochSecond(LocalDate.now(), ZoneOffset.UTC)
@@ -141,14 +149,16 @@ class KeyMapConstraintsComparator(
)
ConstraintData.HingeClosed -> Success("")
+
ConstraintData.HingeOpen -> Success("")
- ConstraintData.KeyboardNotShowing -> Success("")
+
ConstraintData.KeyboardShowing -> Success("")
+
is ConstraintData.PhysicalOrientation -> Success(
constraint.data.physicalOrientation.toString(),
)
+
ConstraintData.NotificationPanelShowing -> Success("")
- ConstraintData.NotificationPanelNotShowing -> Success("")
}
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt
index 130f74f0a1..b09cf0cb8e 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt
@@ -18,7 +18,6 @@ class KeyMapOptionsComparator(
keyMap,
otherKeyMap,
{ it.vibrate },
- { it.trigger.triggerFromOtherApps },
{ it.showToast },
)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt
index 60de372248..b5afb37451 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt
@@ -19,12 +19,13 @@ import io.github.sds100.keymapper.common.utils.State
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
+import kotlinx.serialization.json.Json
@AndroidEntryPoint
class ChooseAppFragment : RecyclerViewFragment() {
companion object {
- const val EXTRA_PACKAGE_NAME = "extra_package_name"
+ const val EXTRA_RESULT = "extra_choose_app_result"
const val SEARCH_STATE_KEY = "key_app_search_state"
}
@@ -48,7 +49,7 @@ class ChooseAppFragment : RecyclerViewFragment()
+ private val _returnResult = MutableSharedFlow()
val returnResult = _returnResult.asSharedFlow()
var allowHiddenApps: Boolean = false
@@ -106,8 +106,9 @@ class ChooseAppViewModel @Inject constructor(private val useCase: DisplayAppsUse
fun onListItemClick(id: String) {
viewModelScope.launch {
val packageName = id
+ val appName = useCase.getAppName(packageName).valueOrNull() ?: packageName
- _returnResult.emit(packageName)
+ _returnResult.emit(ChooseAppResult(packageName = packageName, appName = appName))
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt
index 19769a183a..b07102c2d2 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt
@@ -16,8 +16,6 @@ import io.github.sds100.keymapper.common.utils.DefaultDispatcherProvider
import io.github.sds100.keymapper.common.utils.DispatcherProvider
import io.github.sds100.keymapper.common.utils.onFailure
import io.github.sds100.keymapper.common.utils.onSuccess
-import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager
-import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState
import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState
import io.github.sds100.keymapper.system.notifications.NotificationChannelModel
import io.github.sds100.keymapper.system.notifications.NotificationModel
@@ -44,7 +42,6 @@ class NotificationController @Inject constructor(
private val hideInputMethod: ShowHideInputMethodUseCase,
private val onboardingUseCase: OnboardingUseCase,
private val resourceProvider: ResourceProvider,
- private val systemBridgeConnectionManager: SystemBridgeConnectionManager,
private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(),
) : ResourceProvider by resourceProvider {
@@ -189,15 +186,6 @@ class NotificationController @Inject constructor(
}
}.launchIn(coroutineScope)
- coroutineScope.launch {
- systemBridgeConnectionManager.connectionState
- .collect { connectionState ->
- if (connectionState is SystemBridgeConnectionState.Connected) {
- showSystemBridgeStartedNotification()
- }
- }
- }
-
coroutineScope.launch {
if (onboardingUseCase.showMigrateScreenOffKeyMapsNotification.first()) {
manageNotifications.show(
@@ -414,20 +402,4 @@ class NotificationController @Inject constructor(
onGoing = true,
priority = NotificationCompat.PRIORITY_LOW,
)
-
- private fun showSystemBridgeStartedNotification() {
- val model = NotificationModel(
- id = ID_SYSTEM_BRIDGE_STATUS,
- title = getString(R.string.expert_mode_setup_notification_system_bridge_started_title),
- text = getString(R.string.expert_mode_setup_notification_system_bridge_started_text),
- channel = CHANNEL_SETUP_ASSISTANT,
- icon = R.drawable.offline_bolt_24px,
- onGoing = false,
- showOnLockscreen = false,
- autoCancel = true,
- timeout = 5000,
- )
-
- manageNotifications.show(model)
- }
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt
index 60942a8d8b..2db49676cf 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt
@@ -30,6 +30,7 @@ import io.github.sds100.keymapper.base.utils.ui.LinkType
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.showDialog
+import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate
import io.github.sds100.keymapper.common.models.EvdevDeviceInfo
import io.github.sds100.keymapper.common.utils.AccessibilityServiceError
import io.github.sds100.keymapper.common.utils.InputDeviceUtils
@@ -64,6 +65,7 @@ abstract class BaseConfigTriggerViewModel(
private val systemBridgeConnectionManager: SystemBridgeConnectionManager,
onboardingTipDelegate: OnboardingTipDelegate,
triggerSetupDelegate: TriggerSetupDelegate,
+ private val vibrateConfigDelegate: VibrateConfigDelegate,
resourceProvider: ResourceProvider,
navigationProvider: NavigationProvider,
dialogProvider: DialogProvider,
@@ -101,6 +103,7 @@ abstract class BaseConfigTriggerViewModel(
dialogProvider,
navigationProvider,
resourceProvider,
+ vibrateConfigDelegate,
)
private val _state: MutableStateFlow> =
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt
index 2415f99184..f2faea5c83 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt
@@ -1,6 +1,5 @@
package io.github.sds100.keymapper.base.trigger
-import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,12 +25,13 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowHeightSizeClass
@@ -127,7 +127,7 @@ fun BaseTriggerScreen(
onButtonClick = { viewModel.onTipButtonClick(tip.id) },
)
- Spacer(Modifier.height(8.dp))
+ Spacer(Modifier.height(16.dp))
}
}
@@ -229,12 +229,8 @@ private fun TriggerScreenVertical(
when (configState) {
is ConfigTriggerState.Empty -> {
- Column {
- Box(
- modifier = Modifier
- .weight(1f)
- .padding(16.dp),
- ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Box(modifier = Modifier.weight(1f)) {
discoverScreenContent()
}
@@ -250,10 +246,6 @@ private fun TriggerScreenVertical(
}
is ConfigTriggerState.Loaded -> {
- Spacer(Modifier.height(8.dp))
-
- tipContent()
-
TriggerList(
modifier = Modifier.weight(1f),
triggerList = configState.triggerKeys,
@@ -263,6 +255,7 @@ private fun TriggerScreenVertical(
onMove = onMoveTriggerKey,
onFixErrorClick = onFixErrorClick,
onAddMoreClick = onAddMoreTriggerKeysClick,
+ tipContent = tipContent,
)
if (configState.clickTypeButtons.isNotEmpty()) {
@@ -441,18 +434,18 @@ private fun TriggerList(
onFixErrorClick: (TriggerError) -> Unit,
onMove: (fromIndex: Int, toIndex: Int) -> Unit,
onAddMoreClick: () -> Unit,
+ tipContent: @Composable () -> Unit = {},
) {
val lazyListState = rememberLazyListState()
+ val triggerKeyIds = remember(triggerList) { triggerList.map { it.id } }
+
+ // Only the trigger keys can be dragged. Not the "add more" button.
val dragDropState = rememberDragDropState(
lazyListState = lazyListState,
+ keys = triggerKeyIds,
onMove = onMove,
- // Do not drag and drop the "add more" button
- ignoreLastItems = if (triggerList.isEmpty()) {
- 0
- } else {
- 1
- },
)
+ val orderedTriggerList = dragDropState.ordered(triggerList) { it.id }
// Use dragContainer rather than .draggable() modifier because that causes
// dragging the first item to be always be dropped in the next position.
@@ -462,20 +455,24 @@ private fun TriggerList(
contentPadding = PaddingValues(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
+ item(key = "tip", contentType = "tip") {
+ tipContent()
+ }
+
itemsIndexed(
- triggerList,
+ orderedTriggerList,
key = { _, item -> item.id },
contentType = { _, _ -> "key" },
) { index, model ->
DraggableItem(
dragDropState = dragDropState,
- index = index,
+ key = model.id,
) { isDragging ->
TriggerKeyListItem(
modifier = Modifier.fillMaxWidth(),
model = model,
index = index,
- isDraggingEnabled = triggerList.size > 1,
+ isDraggingEnabled = orderedTriggerList.size > 1,
isDragging = isDragging,
isReorderingEnabled = isReorderingEnabled,
dragDropState = dragDropState,
@@ -487,7 +484,7 @@ private fun TriggerList(
} else {
null
},
- onMoveDown = if (isReorderingEnabled && index < triggerList.size - 1) {
+ onMoveDown = if (isReorderingEnabled && index < orderedTriggerList.size - 1) {
{ onMove(index, index + 1) }
} else {
null
@@ -631,7 +628,7 @@ private val previewState =
triggerModeButtonsVisible = true,
)
-@Preview(device = Devices.PIXEL)
+@PreviewLightDark
@Composable
private fun VerticalPreview() {
KeyMapperTheme {
@@ -646,24 +643,20 @@ private fun VerticalPreview() {
discoverScreenContent = {
TriggerDiscoverScreen()
},
- )
- }
-}
+ tipContent = {
+ TipCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ title = "Tip Title",
+ message = """
+ This is a tip message to help the user understand something about the
+ current screen. It can be quite long so it should wrap properly.
+ """.trimIndent(),
+ onDismiss = {},
+ )
-@Preview(device = Devices.PIXEL)
-@Composable
-private fun VerticalPreviewDark() {
- KeyMapperTheme(darkTheme = true) {
- TriggerScreenVertical(
- configState = previewState,
- recordTriggerState = RecordTriggerState.Idle,
- expertModeSwitchState = ExpertModeRecordSwitchState(
- isVisible = true,
- isChecked = false,
- isEnabled = true,
- ),
- discoverScreenContent = {
- TriggerDiscoverScreen()
+ Spacer(Modifier.height(16.dp))
},
)
}
@@ -731,7 +724,7 @@ private fun PreviewSquareRectangle() {
}
}
-@Preview(device = Devices.PIXEL)
+@PreviewLightDark
@Composable
private fun VerticalEmptyPreview() {
KeyMapperTheme {
@@ -750,25 +743,6 @@ private fun VerticalEmptyPreview() {
}
}
-@Preview(device = Devices.PIXEL, uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Composable
-private fun VerticalEmptyDarkPreview() {
- KeyMapperTheme {
- TriggerScreenVertical(
- configState = ConfigTriggerState.Empty,
- recordTriggerState = RecordTriggerState.Idle,
- expertModeSwitchState = ExpertModeRecordSwitchState(
- isVisible = true,
- isChecked = true,
- isEnabled = false,
- ),
- discoverScreenContent = {
- TriggerDiscoverScreen()
- },
- )
- }
-}
-
@Preview(widthDp = 800, heightDp = 300)
@Composable
private fun HorizontalPreview() {
@@ -797,7 +771,7 @@ private fun HorizontalPreview() {
onDismiss = {},
)
- Spacer(Modifier.height(8.dp))
+ Spacer(Modifier.height(16.dp))
},
)
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt
index 8839dccb64..7c6ab99c09 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt
@@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.trigger
import io.github.sds100.keymapper.base.floating.FloatingButtonData
import io.github.sds100.keymapper.base.keymaps.ClickType
import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.EvdevDeviceInfo
import io.github.sds100.keymapper.system.inputevents.KeyEventUtils
@@ -435,16 +436,23 @@ class ConfigTriggerDelegate {
return trigger.copy(vibrate = enabled).validate()
}
- fun setVibrationDuration(
+ fun setVibrateEffect(
trigger: Trigger,
- duration: Int,
+ effect: VibrateEffect?,
defaultVibrateDuration: Int,
): Trigger {
- return if (duration == defaultVibrateDuration) {
- trigger.copy(vibrateDuration = null).validate()
+ // Store null (use the app-wide default, which can change later) rather than a
+ // custom duration that happens to currently match it.
+ val normalizedEffect = if (
+ effect is VibrateEffect.CustomDuration &&
+ effect.durationMs == defaultVibrateDuration.toLong()
+ ) {
+ null
} else {
- trigger.copy(vibrateDuration = duration).validate()
+ effect
}
+
+ return trigger.copy(vibrateEffect = normalizedEffect).validate()
}
fun setLongPressDelay(trigger: Trigger, delay: Int, defaultLongPressDelay: Int): Trigger {
@@ -479,10 +487,6 @@ class ConfigTriggerDelegate {
return trigger.copy(longPressDoubleVibration = enabled).validate()
}
- fun setTriggerFromOtherAppsEnabled(trigger: Trigger, enabled: Boolean): Trigger {
- return trigger.copy(triggerFromOtherApps = enabled).validate()
- }
-
fun setShowToastEnabled(trigger: Trigger, enabled: Boolean): Trigger {
return trigger.copy(showToast = enabled).validate()
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt
index 1b348dc47e..05bb1e01e3 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt
@@ -7,6 +7,7 @@ import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState
import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase
import io.github.sds100.keymapper.base.keymaps.KeyMap
import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.EvdevDeviceInfo
import io.github.sds100.keymapper.common.utils.InputDeviceUtils
import io.github.sds100.keymapper.common.utils.State
@@ -212,8 +213,8 @@ class ConfigTriggerUseCaseImpl @Inject constructor(
delegate.setVibrateEnabled(trigger, enabled)
}
- override fun setVibrationDuration(duration: Int) = updateTrigger { trigger ->
- delegate.setVibrationDuration(trigger, duration, defaultVibrateDuration.value)
+ override fun setVibrateEffect(effect: VibrateEffect?) = updateTrigger { trigger ->
+ delegate.setVibrateEffect(trigger, effect, defaultVibrateDuration.value)
}
override fun setLongPressDelay(delay: Int) = updateTrigger { trigger ->
@@ -238,12 +239,6 @@ class ConfigTriggerUseCaseImpl @Inject constructor(
}
}
- override fun setTriggerFromOtherAppsEnabled(enabled: Boolean) {
- updateTrigger { trigger ->
- delegate.setTriggerFromOtherAppsEnabled(trigger, enabled)
- }
- }
-
override fun setShowToastEnabled(enabled: Boolean) {
updateTrigger { trigger ->
delegate.setShowToastEnabled(trigger, enabled)
@@ -333,12 +328,11 @@ interface ConfigTriggerUseCase : GetDefaultKeyMapOptionsUseCase {
fun setFingerprintGestureType(keyUid: String, type: FingerprintGestureType)
fun setVibrateEnabled(enabled: Boolean)
- fun setVibrationDuration(duration: Int)
+ fun setVibrateEffect(effect: VibrateEffect?)
fun setLongPressDelay(delay: Int)
fun setDoublePressDelay(delay: Int)
fun setSequenceTriggerTimeout(delay: Int)
fun setLongPressDoubleVibrationEnabled(enabled: Boolean)
- fun setTriggerFromOtherAppsEnabled(enabled: Boolean)
fun setShowToastEnabled(enabled: Boolean)
fun setScanCodeDetectionEnabled(keyUid: String, enabled: Boolean)
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 8d2dd8ea67..34f80b2666 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
@@ -10,12 +10,12 @@ data class KeyMapListItemModel(val isSelected: Boolean, val content: Content) {
data class Content(
val uid: String,
val triggerKeys: List,
- val triggerErrors: List,
val triggerSeparatorIcon: ImageVector,
val actions: List,
val constraintMode: ConstraintMode,
val constraints: List,
val options: List,
- val extraInfo: String?,
+ val isEnabled: Boolean,
+ val hasError: Boolean,
)
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt
index 4150648257..6c2e2b944b 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt
@@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.trigger
import io.github.sds100.keymapper.base.floating.FloatingButtonEntityMapper
import io.github.sds100.keymapper.base.keymaps.ClickType
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.utils.hasFlag
import io.github.sds100.keymapper.common.utils.valueOrNull
import io.github.sds100.keymapper.common.utils.withFlag
@@ -24,9 +25,8 @@ data class Trigger(
val longPressDoubleVibration: Boolean = false,
val longPressDelay: Int? = null,
val doublePressDelay: Int? = null,
- val vibrateDuration: Int? = null,
+ val vibrateEffect: VibrateEffect? = null,
val sequenceTriggerTimeout: Int? = null,
- val triggerFromOtherApps: Boolean = false,
val showToast: Boolean = false,
/**
@@ -90,24 +90,29 @@ object TriggerEntityMapper {
val keys = entity.keys.map { key ->
when (key) {
is AssistantTriggerKeyEntity -> AssistantTriggerKey.fromEntity(key)
+
is KeyEventTriggerKeyEntity -> KeyEventTriggerKey.fromEntity(
key,
)
+
is FloatingButtonKeyEntity -> {
val floatingButton = floatingButtons.find { it.button.uid == key.buttonUid }
FloatingButtonKey.fromEntity(key, floatingButton)
}
is FingerprintTriggerKeyEntity -> FingerprintTriggerKey.fromEntity(key)
+
is EvdevTriggerKeyEntity -> EvdevTriggerKey.fromEntity(key)
}
}
val mode = when {
entity.mode == TriggerEntity.SEQUENCE && keys.size > 1 -> TriggerMode.Sequence
+
entity.mode == TriggerEntity.PARALLEL && keys.size > 1 -> TriggerMode.Parallel(
keys[0].clickType,
)
+
else -> TriggerMode.Undefined
}
@@ -127,15 +132,13 @@ object TriggerEntityMapper {
doublePressDelay = entity.extras.getData(TriggerEntity.EXTRA_DOUBLE_PRESS_DELAY)
.valueOrNull()?.toIntOrNull(),
- vibrateDuration = entity.extras.getData(TriggerEntity.EXTRA_VIBRATION_DURATION)
- .valueOrNull()?.toIntOrNull(),
+ vibrateEffect = readVibrateEffect(entity),
sequenceTriggerTimeout = entity.extras.getData(
TriggerEntity.EXTRA_SEQUENCE_TRIGGER_TIMEOUT,
)
.valueOrNull()?.toIntOrNull(),
- triggerFromOtherApps = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_FROM_OTHER_APPS),
showToast = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_SHOW_TOAST),
legacyDetectScreenOff = entity.flags.hasFlag(
TriggerEntity.TRIGGER_FLAG_SCREEN_OFF_TRIGGERS,
@@ -175,13 +178,40 @@ object TriggerEntityMapper {
)
}
- if (trigger.isChangingVibrationDurationAllowed() && trigger.vibrateDuration != null) {
- extras.add(
- EntityExtra(
- TriggerEntity.EXTRA_VIBRATION_DURATION,
- trigger.vibrateDuration.toString(),
- ),
- )
+ if (trigger.isChangingVibrationDurationAllowed()) {
+ when (val effect = trigger.vibrateEffect) {
+ is VibrateEffect.CustomDuration -> {
+ extras.add(
+ EntityExtra(
+ TriggerEntity.EXTRA_VIBRATE_MODE,
+ TriggerEntity.VIBRATE_MODE_DURATION,
+ ),
+ )
+ extras.add(
+ EntityExtra(
+ TriggerEntity.EXTRA_VIBRATION_DURATION,
+ effect.durationMs.toString(),
+ ),
+ )
+ }
+
+ is VibrateEffect.Predefined -> {
+ extras.add(
+ EntityExtra(
+ TriggerEntity.EXTRA_VIBRATE_MODE,
+ TriggerEntity.VIBRATE_MODE_PREDEFINED,
+ ),
+ )
+ extras.add(
+ EntityExtra(
+ TriggerEntity.EXTRA_VIBRATE_EFFECT_TYPE,
+ effect.predefinedType.name,
+ ),
+ )
+ }
+
+ null -> {}
+ }
}
val mode = when (trigger.mode) {
@@ -200,10 +230,6 @@ object TriggerEntityMapper {
flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_LONG_PRESS_DOUBLE_VIBRATION)
}
- if (trigger.triggerFromOtherApps) {
- flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_FROM_OTHER_APPS)
- }
-
if (trigger.showToast) {
flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_SHOW_TOAST)
}
@@ -218,11 +244,15 @@ object TriggerEntityMapper {
val keys = trigger.keys.map { key ->
when (key) {
is AssistantTriggerKey -> AssistantTriggerKey.toEntity(key)
+
is KeyEventTriggerKey -> KeyEventTriggerKey.toEntity(
key,
)
+
is FloatingButtonKey -> FloatingButtonKey.toEntity(key)
+
is FingerprintTriggerKey -> FingerprintTriggerKey.toEntity(key)
+
is EvdevTriggerKey -> EvdevTriggerKey.toEntity(key)
}
}
@@ -234,4 +264,42 @@ object TriggerEntityMapper {
flags = flags,
)
}
+
+ private fun readVibrateEffect(entity: TriggerEntity): VibrateEffect? {
+ val mode = entity.extras.getData(TriggerEntity.EXTRA_VIBRATE_MODE).valueOrNull()
+
+ if (mode != null) {
+ return when (mode) {
+ TriggerEntity.VIBRATE_MODE_DURATION -> {
+ val durationMs = entity.extras.getData(
+ TriggerEntity.EXTRA_VIBRATION_DURATION,
+ ).valueOrNull()?.toLongOrNull() ?: return null
+
+ VibrateEffect.CustomDuration(durationMs)
+ }
+
+ TriggerEntity.VIBRATE_MODE_PREDEFINED -> {
+ val typeString = entity.extras.getData(
+ TriggerEntity.EXTRA_VIBRATE_EFFECT_TYPE,
+ ).valueOrNull() ?: return null
+
+ val type = try {
+ VibrateEffect.PredefinedType.valueOf(typeString)
+ } catch (_: IllegalArgumentException) {
+ return null
+ }
+
+ VibrateEffect.Predefined(type)
+ }
+
+ else -> null
+ }
+ }
+
+ // Key maps saved before this field existed only stored a raw duration.
+ val legacyDurationMs = entity.extras.getData(TriggerEntity.EXTRA_VIBRATION_DURATION)
+ .valueOrNull()?.toIntOrNull() ?: return null
+
+ return VibrateEffect.CustomDuration(legacyDurationMs.toLong())
+ }
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt
index cf17a0def7..7eb89a6997 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt
@@ -9,6 +9,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
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
@@ -60,7 +61,7 @@ fun TriggerDiscoverBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewNoKeyRecordedComplete() {
KeyMapperTheme {
@@ -68,6 +69,7 @@ private fun PreviewNoKeyRecordedComplete() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TriggerDiscoverBottomSheet(
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt
index 51815ff739..f12b644da5 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -53,12 +54,14 @@ fun TriggerDiscoverScreen(
) {
val customColors = LocalCustomColorsPalette.current
+ val horizontalPadding = 16.dp
+
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- Column {
+ Column(modifier = Modifier.padding(horizontal = horizontalPadding)) {
Text(
text = stringResource(R.string.trigger_discover_screen_title),
style = MaterialTheme.typography.headlineSmall,
@@ -74,6 +77,7 @@ fun TriggerDiscoverScreen(
}
TriggerSection(
+ modifier = Modifier.padding(horizontal = horizontalPadding),
title = stringResource(R.string.trigger_discover_section_on_device_buttons),
shortcuts = buildList {
add(
@@ -114,6 +118,7 @@ fun TriggerDiscoverScreen(
)
TriggerSection(
+ modifier = Modifier.padding(horizontal = horizontalPadding),
title = stringResource(R.string.trigger_discover_section_peripherals_gaming),
shortcuts = listOf(
ShortcutData(
@@ -142,6 +147,7 @@ fun TriggerDiscoverScreen(
AnimatedVisibility(visible = showFloatingButtons) {
TriggerSection(
+ modifier = Modifier.padding(horizontal = horizontalPadding),
title = stringResource(R.string.trigger_discover_section_floating_buttons),
shortcuts = listOf(
ShortcutData(
@@ -170,12 +176,14 @@ fun TriggerDiscoverScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TriggerSection(
+ modifier: Modifier = Modifier,
title: String,
shortcuts: List,
onShortcutClick: (TriggerSetupShortcut) -> Unit,
backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
) {
Column(
+ modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt
index 6370ac90b2..79a29c0068 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt
@@ -22,12 +22,10 @@ import androidx.compose.material.icons.outlined.OfflineBolt
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDownward
-import androidx.compose.material.icons.rounded.Clear
+import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.DragHandle
-import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
-import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
@@ -49,6 +47,7 @@ import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.base.keymaps.ClickType
import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType
import io.github.sds100.keymapper.base.utils.ui.LinkType
+import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton
import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState
@Composable
@@ -127,8 +126,8 @@ fun TriggerKeyListItem(
enabled = isDraggingEnabled,
orientation = Orientation.Vertical,
startDragImmediately = true,
- onDragStarted = { offset ->
- dragDropState?.onDragStart(index, offset)
+ onDragStarted = {
+ dragDropState?.onDragStart(model.id)
},
onDragStopped = { dragDropState?.onDragInterrupted() },
),
@@ -157,11 +156,11 @@ fun TriggerKeyListItem(
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
+
+ Spacer(Modifier.width(8.dp))
}
}
- Spacer(Modifier.width(8.dp))
-
if (model.error == null) {
val clickTypeString = when (model.clickType) {
ClickType.SHORT_PRESS -> null
@@ -194,24 +193,19 @@ fun TriggerKeyListItem(
)
}
+ if (model.error?.isFixable ?: false) {
+ Spacer(Modifier.width(8.dp))
+
+ CompactErrorButton(onClick = { onFixClick(model.error!!) }) {
+ Text(text = stringResource(R.string.button_fix))
+ }
+
+ Spacer(Modifier.width(4.dp))
+ }
+
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 16.dp,
) {
- if (model.error != null && model.error?.isFixable ?: false) {
- FilledTonalButton(
- modifier = Modifier.padding(start = 8.dp, end = 8.dp),
- onClick = { onFixClick(model.error!!) },
- colors = ButtonDefaults.filledTonalButtonColors(
- containerColor = MaterialTheme.colorScheme.error,
- contentColor = MaterialTheme.colorScheme.onError,
- ),
- ) {
- Text(
- text = stringResource(R.string.button_fix),
- )
- }
- }
-
if (model !is TriggerKeyListItemModel.FloatingButtonDeleted) {
IconButton(onClick = onEditClick) {
Icon(
@@ -227,11 +221,11 @@ fun TriggerKeyListItem(
IconButton(onClick = onRemoveClick) {
Icon(
- imageVector = Icons.Rounded.Clear,
+ imageVector = Icons.Rounded.DeleteOutline,
contentDescription = stringResource(
R.string.trigger_key_list_item_remove,
),
- tint = MaterialTheme.colorScheme.onSurface,
+ tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
}
@@ -375,20 +369,20 @@ private fun TextColumn(
) {
Text(
text = primaryText,
- style = MaterialTheme.typography.bodyLarge,
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (secondaryText != null) {
Text(
text = secondaryText,
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
if (tertiaryText != null) {
Text(
text = tertiaryText,
- style = MaterialTheme.typography.bodySmall,
+ style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
@@ -396,13 +390,17 @@ private fun TextColumn(
}
@Composable
-private fun ErrorTextColumn(modifier: Modifier = Modifier, primaryText: String, errorText: String) {
+private fun ErrorTextColumn(
+ modifier: Modifier = Modifier,
+ primaryText: String,
+ errorText: String,
+) {
Column(
modifier = modifier,
) {
Text(
text = primaryText,
- style = MaterialTheme.typography.bodyLarge,
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -410,7 +408,7 @@ private fun ErrorTextColumn(modifier: Modifier = Modifier, primaryText: String,
Text(
text = errorText,
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
)
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt
index 5a90be8d5c..00989420cd 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt
@@ -21,6 +21,7 @@ import androidx.compose.material3.IconButton
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.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
@@ -41,9 +42,9 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme
import io.github.sds100.keymapper.base.keymaps.ClickType
import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType
import io.github.sds100.keymapper.base.utils.ui.CheckBoxListItem
-import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText
import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow
import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText
+import io.github.sds100.keymapper.base.utils.ui.compose.SwitchText
import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe
import io.github.sds100.keymapper.system.inputevents.Scancode
import kotlinx.coroutines.launch
@@ -109,7 +110,7 @@ fun TriggerKeyOptionsBottomSheet(
isCompact = isCompact,
)
- CheckBoxText(
+ SwitchText(
modifier = Modifier.padding(8.dp),
text = stringResource(R.string.flag_dont_override_default_action),
isChecked = state.doNotRemapChecked,
@@ -128,7 +129,7 @@ fun TriggerKeyOptionsBottomSheet(
isCompact = isCompact,
)
- CheckBoxText(
+ SwitchText(
modifier = Modifier.padding(8.dp),
text = stringResource(R.string.flag_dont_override_default_action),
isChecked = state.doNotRemapChecked,
@@ -403,7 +404,7 @@ private fun isVerticalCompactLayout(): Boolean {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewKeyEvent() {
KeyMapperTheme {
@@ -411,6 +412,7 @@ private fun PreviewKeyEvent() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TriggerKeyOptionsBottomSheet(
@@ -441,7 +443,7 @@ private fun PreviewKeyEvent() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview(heightDp = 400, widthDp = 300)
+@Preview(heightDp = 400, widthDp = 300, showSystemUi = true)
@Composable
private fun PreviewKeyEventTiny() {
KeyMapperTheme {
@@ -449,6 +451,7 @@ private fun PreviewKeyEventTiny() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TriggerKeyOptionsBottomSheet(
@@ -479,7 +482,7 @@ private fun PreviewKeyEventTiny() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewEvdev() {
KeyMapperTheme {
@@ -487,6 +490,7 @@ private fun PreviewEvdev() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TriggerKeyOptionsBottomSheet(
@@ -505,7 +509,7 @@ private fun PreviewEvdev() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun AssistantPreview() {
KeyMapperTheme {
@@ -513,6 +517,7 @@ private fun AssistantPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TriggerKeyOptionsBottomSheet(
@@ -526,7 +531,7 @@ private fun AssistantPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun FloatingButtonPreview() {
KeyMapperTheme {
@@ -534,6 +539,7 @@ private fun FloatingButtonPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
TriggerKeyOptionsBottomSheet(
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt
index 1131d4f5a1..9530e30cc2 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt
@@ -32,6 +32,7 @@ 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.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
@@ -909,7 +910,7 @@ fun TriggerSetupBottomSheet(
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PowerButtonPreview() {
KeyMapperTheme {
@@ -917,6 +918,7 @@ private fun PowerButtonPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
PowerTriggerSetupBottomSheet(
@@ -933,7 +935,7 @@ private fun PowerButtonPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PowerButtonDisabledPreview() {
KeyMapperTheme {
@@ -941,6 +943,7 @@ private fun PowerButtonDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
PowerTriggerSetupBottomSheet(
@@ -957,7 +960,7 @@ private fun PowerButtonDisabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun VolumeButtonPreview() {
KeyMapperTheme {
@@ -965,6 +968,7 @@ private fun VolumeButtonPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
VolumeTriggerSetupBottomSheet(
@@ -982,7 +986,7 @@ private fun VolumeButtonPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun VolumeButtonDisabledPreview() {
KeyMapperTheme {
@@ -990,6 +994,7 @@ private fun VolumeButtonDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
VolumeTriggerSetupBottomSheet(
@@ -1007,7 +1012,7 @@ private fun VolumeButtonDisabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun FingerprintGestureRequirementsMetPreview() {
KeyMapperTheme {
@@ -1015,6 +1020,7 @@ private fun FingerprintGestureRequirementsMetPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
FingerprintGestureSetupBottomSheet(
@@ -1029,7 +1035,7 @@ private fun FingerprintGestureRequirementsMetPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun FingerprintGestureRequirementsNotMetPreview() {
KeyMapperTheme {
@@ -1037,6 +1043,7 @@ private fun FingerprintGestureRequirementsNotMetPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
FingerprintGestureSetupBottomSheet(
@@ -1051,7 +1058,7 @@ private fun FingerprintGestureRequirementsNotMetPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun KeyboardButtonEnabledPreview() {
KeyMapperTheme {
@@ -1059,6 +1066,7 @@ private fun KeyboardButtonEnabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
KeyboardTriggerSetupBottomSheet(
@@ -1076,7 +1084,7 @@ private fun KeyboardButtonEnabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun KeyboardButtonDisabledPreview() {
KeyMapperTheme {
@@ -1084,6 +1092,7 @@ private fun KeyboardButtonDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
KeyboardTriggerSetupBottomSheet(
@@ -1101,7 +1110,7 @@ private fun KeyboardButtonDisabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun MouseButtonPreview() {
KeyMapperTheme {
@@ -1109,6 +1118,7 @@ private fun MouseButtonPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
MouseTriggerSetupBottomSheet(
@@ -1125,7 +1135,7 @@ private fun MouseButtonPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun MouseButtonDisabledPreview() {
KeyMapperTheme {
@@ -1133,6 +1143,7 @@ private fun MouseButtonDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
MouseTriggerSetupBottomSheet(
@@ -1149,7 +1160,7 @@ private fun MouseButtonDisabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun OtherButtonPreview() {
KeyMapperTheme {
@@ -1157,6 +1168,7 @@ private fun OtherButtonPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
OtherTriggerSetupBottomSheet(
@@ -1174,7 +1186,7 @@ private fun OtherButtonPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun OtherButtonDisabledPreview() {
KeyMapperTheme {
@@ -1182,6 +1194,7 @@ private fun OtherButtonDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
OtherTriggerSetupBottomSheet(
@@ -1199,7 +1212,7 @@ private fun OtherButtonDisabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun GamepadDpadPreview() {
KeyMapperTheme {
@@ -1207,6 +1220,7 @@ private fun GamepadDpadPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
GamepadTriggerSetupBottomSheet(
@@ -1224,7 +1238,7 @@ private fun GamepadDpadPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun GamepadDpadDisabledPreview() {
KeyMapperTheme {
@@ -1232,6 +1246,7 @@ private fun GamepadDpadDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
GamepadTriggerSetupBottomSheet(
@@ -1249,7 +1264,7 @@ private fun GamepadDpadDisabledPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun GamepadSimpleButtonsPreview() {
KeyMapperTheme {
@@ -1257,6 +1272,7 @@ private fun GamepadSimpleButtonsPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
GamepadTriggerSetupBottomSheet(
@@ -1274,7 +1290,7 @@ private fun GamepadSimpleButtonsPreview() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun GamepadSimpleButtonsDisabledPreview() {
KeyMapperTheme {
@@ -1282,6 +1298,7 @@ private fun GamepadSimpleButtonsDisabledPreview() {
skipPartiallyExpanded = true,
positionalThreshold = { 0f },
velocityThreshold = { 0f },
+ initialValue = SheetValue.Expanded,
)
GamepadTriggerSetupBottomSheet(
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 d0dd8793ea..187ffb2137 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
@@ -6,6 +6,7 @@ import android.telephony.SmsManager
import io.github.sds100.keymapper.base.R
import io.github.sds100.keymapper.base.purchasing.PurchasingError
import io.github.sds100.keymapper.base.purchasing.RevenueCatEntitlementId
+import io.github.sds100.keymapper.base.trigger.TriggerError
import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl
import io.github.sds100.keymapper.common.utils.AccessibilityServiceError
@@ -85,16 +86,19 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String {
resourceProvider.getString(resId)
}
- is KMError.AppNotFound ->
- resourceProvider.getString(
- R.string.error_app_isnt_installed,
- packageName,
- )
+ is KMError.AppNotFound -> {
+ val name = appName
+ if (name != null) {
+ resourceProvider.getString(R.string.error_app_isnt_installed_named, name)
+ } else {
+ resourceProvider.getString(R.string.error_app_isnt_installed, packageName)
+ }
+ }
is KMError.AppDisabled ->
resourceProvider.getString(
R.string.error_app_is_disabled_package_name,
- this.packageName,
+ this.appName ?: this.packageName,
)
is KMError.NoCompatibleImeEnabled ->
@@ -612,3 +616,38 @@ val KMError.isFixable: Boolean
else ->
false
}
+
+fun TriggerError.getFullMessage(resourceProvider: ResourceProvider): String = when (this) {
+ TriggerError.DND_ACCESS_DENIED ->
+ resourceProvider.getString(R.string.trigger_error_dnd_access_denied)
+
+ TriggerError.CANT_DETECT_IN_PHONE_CALL ->
+ resourceProvider.getString(R.string.trigger_error_cant_detect_in_phone_call)
+
+ TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED ->
+ resourceProvider.getString(R.string.trigger_error_assistant_not_purchased)
+
+ TriggerError.DPAD_IME_NOT_SELECTED ->
+ resourceProvider.getString(R.string.trigger_error_dpad_ime_not_selected)
+
+ TriggerError.FLOATING_BUTTON_DELETED ->
+ resourceProvider.getString(R.string.trigger_error_floating_button_deleted)
+
+ TriggerError.FLOATING_BUTTONS_NOT_PURCHASED ->
+ resourceProvider.getString(R.string.trigger_error_floating_buttons_not_purchased)
+
+ TriggerError.PURCHASE_VERIFICATION_FAILED ->
+ resourceProvider.getString(R.string.trigger_error_product_verification_failed)
+
+ TriggerError.SYSTEM_BRIDGE_UNSUPPORTED ->
+ resourceProvider.getString(R.string.trigger_error_system_bridge_unsupported)
+
+ TriggerError.SYSTEM_BRIDGE_DISCONNECTED ->
+ resourceProvider.getString(R.string.trigger_error_system_bridge_disconnected)
+
+ TriggerError.EVDEV_DEVICE_NOT_FOUND ->
+ resourceProvider.getString(R.string.trigger_error_evdev_device_not_found)
+
+ TriggerError.MIGRATE_SCREEN_OFF_TRIGGER ->
+ resourceProvider.getString(R.string.trigger_error_migrate_screen_off_key_map)
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt
new file mode 100644
index 0000000000..53b0ff4cf6
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt
@@ -0,0 +1,13 @@
+package io.github.sds100.keymapper.base.utils
+
+import io.github.sds100.keymapper.base.R
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+
+object VibrateEffectStrings {
+ fun getLabel(type: VibrateEffect.PredefinedType) = when (type) {
+ VibrateEffect.PredefinedType.CLICK -> R.string.vibration_effect_click
+ VibrateEffect.PredefinedType.DOUBLE_CLICK -> R.string.vibration_effect_double_click
+ VibrateEffect.PredefinedType.HEAVY_CLICK -> R.string.vibration_effect_heavy_click
+ VibrateEffect.PredefinedType.TICK -> R.string.vibration_effect_tick
+ }
+}
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 4849afedb6..ba50beebc0 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
@@ -6,6 +6,7 @@ import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateRe
import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult
import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult
import io.github.sds100.keymapper.base.constraints.ConstraintData
+import io.github.sds100.keymapper.base.system.apps.ChooseAppResult
import io.github.sds100.keymapper.base.system.apps.ChooseAppShortcutResult
import io.github.sds100.keymapper.base.system.intents.ConfigIntentResult
import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut
@@ -47,6 +48,9 @@ abstract class NavDestination(val isCompose: Boolean = false) {
const val ID_ADVANCED_TRIGGERS = "advanced_triggers"
const val ID_GET_EVENT = "get_event"
const val ID_XIAOMI_OPTIMIZATION = "xiaomi_optimization"
+ 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"
}
@Serializable
@@ -60,7 +64,7 @@ abstract class NavDestination(val isCompose: Boolean = false) {
* Allow the list to show hidden apps that can't be launched.
*/
val allowHiddenApps: Boolean,
- ) : NavDestination() {
+ ) : NavDestination() {
override val id: String = ID_CHOOSE_APP
}
@@ -124,6 +128,12 @@ abstract class NavDestination(val isCompose: Boolean = false) {
override val id: String = ID_CHOOSE_CONSTRAINT
}
+ @Serializable
+ data object ConfigNotificationConstraint :
+ NavDestination(isCompose = true) {
+ override val id: String = ID_CONFIG_NOTIFICATION_CONSTRAINT
+ }
+
@Serializable
data object ChooseBluetoothDevice : NavDestination() {
override val id: String = ID_CHOOSE_BLUETOOTH_DEVICE
@@ -223,4 +233,14 @@ abstract class NavDestination(val isCompose: Boolean = false) {
data object XiaomiOptimization : NavDestination(isCompose = true) {
override val id: String = ID_XIAOMI_OPTIMIZATION
}
+
+ @Serializable
+ data class TriggerByIntent(val keyMapUid: String) : NavDestination(isCompose = true) {
+ override val id: String = ID_TRIGGER_BY_INTENT
+ }
+
+ @Serializable
+ data class EnableByIntent(val keyMapUid: String) : NavDestination(isCompose = true) {
+ override val id: String = ID_ENABLE_BY_INTENT
+ }
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt
index e47277917a..06384ccca3 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt
@@ -377,9 +377,9 @@ fun NavigationProvider.sendNavResultFromBundle(
) {
when (destinationId) {
NavDestination.ID_CHOOSE_APP -> {
- val packageName = bundle.getString(ChooseAppFragment.EXTRA_PACKAGE_NAME)
+ val json = bundle.getString(ChooseAppFragment.EXTRA_RESULT)
- onNavResult(NavResult(requestKey, Json.encodeToString(packageName!!)))
+ onNavResult(NavResult(requestKey, json))
}
NavDestination.ID_CHOOSE_APP_SHORTCUT -> {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt
index 6c24de3d8d..cfc4958650 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.drawable.Drawable
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
+import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -23,6 +24,10 @@ class ResourceProviderImpl @Inject constructor(@ApplicationContext context: Cont
override fun getString(resId: Int): String = ctx.str(resId)
+ override fun getPluralString(resId: Int, quantity: Int, vararg args: Any): String {
+ return ctx.resources.getQuantityString(resId, quantity, *args)
+ }
+
override fun getDrawable(resId: Int): Drawable = ctx.drawable(resId)
override fun getColor(color: Int): Int = ctx.color(color)
@@ -32,6 +37,8 @@ interface ResourceProvider {
fun getString(@StringRes resId: Int, args: Array): String
fun getString(@StringRes resId: Int, arg: Any): String
fun getString(@StringRes resId: Int): String
+ fun getPluralString(@PluralsRes resId: Int, quantity: Int, vararg args: Any): String
+
fun getText(@StringRes resId: Int): CharSequence
fun getDrawable(@DrawableRes resId: Int): Drawable
fun getColor(@ColorRes color: Int): Int
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt
index 5a4c852b8e..bc3fb8b06a 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt
@@ -9,5 +9,5 @@ object SliderMinimums {
const val TRIGGER_LONG_PRESS_DELAY = 0
const val TRIGGER_DOUBLE_PRESS_DELAY = 0
const val TRIGGER_SEQUENCE_TRIGGER_TIMEOUT = 0
- const val VIBRATION_DURATION = 1
+ const val VIBRATION_DURATION = 0
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt
index b11c34673b..2e36b85efd 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt
@@ -26,7 +26,7 @@ fun UnsavedChangesDialog(onDismiss: () -> Unit, onDiscardClick: () -> Unit) {
)
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun Preview() {
KeyMapperTheme {
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
new file mode 100644
index 0000000000..fa0658fc46
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt
@@ -0,0 +1,165 @@
+package io.github.sds100.keymapper.base.utils.ui.compose
+
+import android.content.ClipData
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ContentCopy
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+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
+
+object CodeBlockDefaults {
+ /**
+ * Soft wrap the code after this many characters so that long commands stay readable.
+ */
+ const val MAX_LINE_LENGTH: Int = 80
+
+ val MaxHeight: Dp = 240.dp
+}
+
+/**
+ * Shows monospace text that can be selected, scrolled in both directions and copied.
+ */
+@Composable
+fun CodeBlock(
+ modifier: Modifier = Modifier,
+ code: String,
+ clipboardLabel: String,
+ label: String? = null,
+ maxLineLength: Int = CodeBlockDefaults.MAX_LINE_LENGTH,
+ maxHeight: Dp = CodeBlockDefaults.MaxHeight,
+) {
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ val density = LocalDensity.current
+ val textMeasurer = rememberTextMeasurer()
+
+ val textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
+
+ // The code only wraps once it is this wide, so narrow screens scroll horizontally
+ // instead of wrapping every few words.
+ val lineWidth = remember(textStyle, maxLineLength, density, textMeasurer) {
+ val widthPx = textMeasurer.measure(
+ text = "0".repeat(maxLineLength),
+ style = textStyle,
+ softWrap = false,
+ ).size.width
+
+ with(density) { widthPx.toDp() }
+ }
+
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (label != null) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ SelectionContainer(
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(max = maxHeight)
+ .verticalScroll(rememberScrollState())
+ .horizontalScroll(rememberScrollState()),
+ ) {
+ Text(
+ modifier = Modifier
+ .padding(start = 12.dp, top = 8.dp, bottom = 8.dp)
+ .widthIn(max = lineWidth),
+ text = code,
+ style = textStyle,
+ )
+ }
+
+ IconButton(
+ onClick = {
+ scope.launch {
+ clipboard.setClipEntry(
+ ClipEntry(ClipData.newPlainText(clipboardLabel, code)),
+ )
+ }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ContentCopy,
+ contentDescription = if (label == null) {
+ stringResource(R.string.code_block_copy)
+ } else {
+ stringResource(R.string.code_block_copy_content_description, label)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CodeBlockPreview() {
+ KeyMapperTheme {
+ Surface {
+ CodeBlock(
+ modifier = Modifier.padding(16.dp),
+ label = "Command",
+ code = "adb shell am broadcast -n io.github.sds100.keymapper/io.github." +
+ "sds100.keymapper.api.TriggerKeyMapsBroadcastReceiver " +
+ "-a io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID " +
+ "--es io.github.sds100.keymapper.EXTRA_KEYMAP_UID " +
+ "beea7ef5-e33e-4bd3-9987-9002e5035f23",
+ clipboardLabel = "Command",
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CodeBlockShortPreview() {
+ KeyMapperTheme {
+ Surface {
+ CodeBlock(
+ modifier = Modifier.padding(16.dp),
+ code = "io.github.sds100.keymapper.debug",
+ clipboardLabel = "Package",
+ )
+ }
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt
new file mode 100644
index 0000000000..234b4eccb1
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt
@@ -0,0 +1,116 @@
+package io.github.sds100.keymapper.base.utils.ui.compose
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import io.github.sds100.keymapper.base.compose.KeyMapperTheme
+
+private val compactButtonHeight = 28.dp
+private val compactButtonContentPadding = PaddingValues(horizontal = 12.dp, vertical = 2.dp)
+
+@Composable
+fun CompactOutlinedButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.outlinedButtonColors(),
+ border: BorderStroke = ButtonDefaults.outlinedButtonBorder(),
+ content: @Composable RowScope.() -> Unit,
+) {
+ OutlinedButton(
+ modifier = modifier.height(compactButtonHeight),
+ onClick = onClick,
+ enabled = enabled,
+ shape = MaterialTheme.shapes.small,
+ colors = colors,
+ contentPadding = compactButtonContentPadding,
+ border = border,
+ content = content,
+ )
+}
+
+@Composable
+fun CompactFilledTonalButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(),
+ content: @Composable RowScope.() -> Unit,
+) {
+ FilledTonalButton(
+ modifier = modifier.height(compactButtonHeight),
+ onClick = onClick,
+ enabled = enabled,
+ shape = MaterialTheme.shapes.small,
+ colors = colors,
+ contentPadding = compactButtonContentPadding,
+ content = content,
+ )
+}
+
+@Composable
+fun CompactErrorButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Button(
+ modifier = modifier.height(compactButtonHeight),
+ onClick = onClick,
+ enabled = enabled,
+ shape = MaterialTheme.shapes.small,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ contentPadding = compactButtonContentPadding,
+ content = content,
+ )
+}
+
+@Composable
+private fun CompactButtonsPreviewContent() {
+ Surface {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CompactOutlinedButton(onClick = {}) {
+ Text("Outlined")
+ }
+
+ CompactFilledTonalButton(onClick = {}) {
+ Text("Tonal")
+ }
+
+ CompactErrorButton(onClick = {}) {
+ Text("Fix")
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun PreviewLight() {
+ KeyMapperTheme {
+ CompactButtonsPreviewContent()
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt
index 8485de387f..705ce63916 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt
@@ -1,5 +1,6 @@
package io.github.sds100.keymapper.base.utils.ui.compose
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -30,8 +31,10 @@ fun CompactChip(
icon: (@Composable () -> Unit)? = null,
containerColor: Color = MaterialTheme.colorScheme.surfaceContainer,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ borderStroke: BorderStroke? = null,
onClick: (() -> Unit)? = null,
enabled: Boolean = false,
+ actionContent: (@Composable () -> Unit)? = null,
) {
CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 16.dp,
@@ -40,43 +43,63 @@ fun CompactChip(
Surface(
modifier = modifier.height(chipHeight),
color = containerColor,
+ contentColor = contentColor,
+ border = borderStroke,
shape = AssistChipDefaults.shape,
) {
- CompactChipContent(icon, text, contentColor)
+ CompactChipContent(icon, text, contentColor, actionContent = actionContent)
}
} else {
Surface(
modifier = modifier.height(chipHeight),
color = containerColor,
+ contentColor = contentColor,
+ border = borderStroke,
shape = AssistChipDefaults.shape,
onClick = onClick,
) {
- CompactChipContent(icon, text, contentColor)
+ CompactChipContent(icon, text, contentColor, actionContent = actionContent)
}
}
}
}
@Composable
-fun ErrorCompactChip(onClick: () -> Unit, text: String, enabled: Boolean) {
+fun ErrorCompactChip(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ text: String,
+ enabled: Boolean,
+ actionContent: (@Composable () -> Unit)? = null,
+) {
CompactChip(
+ modifier = modifier,
text = text,
+ containerColor = containerColor,
+ contentColor = contentColor,
icon = {
Icon(
modifier = Modifier.fillMaxHeight(),
imageVector = Icons.Outlined.Error,
+ tint = MaterialTheme.colorScheme.error,
contentDescription = null,
)
},
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer,
onClick = onClick,
enabled = enabled,
+ actionContent = actionContent,
)
}
@Composable
-private fun CompactChipContent(icon: @Composable (() -> Unit)?, text: String, contentColor: Color) {
+private fun CompactChipContent(
+ icon: @Composable (() -> Unit)?,
+ text: String,
+ contentColor: Color,
+ actionContent: (@Composable () -> Unit)? = null,
+) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -93,5 +116,10 @@ private fun CompactChipContent(icon: @Composable (() -> Unit)?, text: String, co
style = MaterialTheme.typography.labelLarge,
color = contentColor,
)
+
+ if (actionContent != null) {
+ Spacer(modifier = Modifier.width(4.dp))
+ actionContent()
+ }
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt
index 15f44addaf..992fda2688 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt
@@ -8,6 +8,10 @@ sealed class ComposeChipModel {
override val id: String,
val icon: ComposeIconInfo?,
override val text: String,
+ /**
+ * Disabled chips are greyed out.
+ */
+ val isEnabled: Boolean = true,
) : ComposeChipModel()
data class Error(
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 f05cf9f5f5..fb6cb0f2ad 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
@@ -9,25 +9,147 @@ 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.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.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.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.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
+
+@Composable
+fun TextFieldDialog(
+ title: String,
+ submitButtonText: String,
+ initialText: String,
+ hint: String? = null,
+ canBeEmpty: Boolean = false,
+ /**
+ * Returns an error message.
+ */
+ onSubmitClick: suspend (newText: String) -> String? = { null },
+ onDismissRequest: () -> Unit = {},
+) {
+ val scope = rememberCoroutineScope()
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val focusRequester = remember { FocusRequester() }
+ var textFieldValue: TextFieldValue by remember {
+ mutableStateOf(
+ TextFieldValue(
+ text = initialText,
+ selection = TextRange(initialText.length),
+ ),
+ )
+ }
+
+ var error: String? by remember { mutableStateOf(null) }
+
+ val isError by remember { derivedStateOf { error != null } }
+
+ val emptyErrorText = stringResource(R.string.error_cant_be_empty)
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ keyboardController?.show()
+ }
+
+ val submit = {
+ scope.launch {
+ error = onSubmitClick(textFieldValue.text)
+
+ if (error == null) {
+ onDismissRequest()
+ }
+ }
+ Unit
+ }
+
+ CustomDialog(
+ title = title,
+ confirmButton = {
+ TextButton(
+ onClick = submit,
+ enabled = !isError,
+ ) {
+ Text(submitButtonText)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(stringResource(R.string.neg_cancel))
+ }
+ },
+ onDismissRequest = onDismissRequest,
+ ) {
+ TextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .focusRequester(focusRequester),
+ value = textFieldValue,
+ onValueChange = {
+ error = null
+ textFieldValue = it
+
+ if (!canBeEmpty && textFieldValue.text.isBlank()) {
+ error = emptyErrorText
+ }
+ },
+ placeholder = if (hint == null) {
+ null
+ } else {
+ { Text(hint) }
+ },
+ singleLine = true,
+ maxLines = 1,
+ isError = isError,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (!isError) {
+ submit()
+ }
+ },
+ ),
+ supportingText = {
+ if (error != null) Text(error!!)
+ },
+ )
+ }
+}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomDialog(
- title: String,
+ title: String? = null,
text: String? = null,
confirmButton: @Composable () -> Unit = {},
- dismissButton: @Composable () -> Unit,
+ dismissButton: (@Composable () -> Unit)? = null,
onDismissRequest: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
@@ -40,10 +162,10 @@ fun CustomDialog(
@Composable
fun CustomDialogContent(
- title: String,
+ title: String?,
text: String? = null,
confirmButton: @Composable () -> Unit,
- dismissButton: @Composable () -> Unit = {},
+ dismissButton: (@Composable () -> Unit)? = null,
content: @Composable (BoxScope.() -> Unit),
) {
Surface(
@@ -52,16 +174,20 @@ fun CustomDialogContent(
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column {
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- modifier = Modifier
- .align(Alignment.Start)
- .fillMaxWidth()
- .padding(start = 24.dp, end = 24.dp),
- text = title,
- style = MaterialTheme.typography.headlineSmall,
- color = AlertDialogDefaults.titleContentColor,
- )
+ if (title != null) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ modifier = Modifier
+ .align(Alignment.Start)
+ .fillMaxWidth()
+ .padding(start = 24.dp, end = 24.dp),
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = AlertDialogDefaults.titleContentColor,
+ )
+ }
+
if (text != null) {
Spacer(modifier = Modifier.height(16.dp))
@@ -75,22 +201,51 @@ fun CustomDialogContent(
color = AlertDialogDefaults.textContentColor,
)
}
- Spacer(modifier = Modifier.height(16.dp))
+
+ Spacer(modifier = Modifier.height(8.dp))
// HorizontalDivider()
Box(Modifier.weight(1f, fill = false), content = content)
// HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.End)
- .padding(16.dp),
- horizontalArrangement = Arrangement.End,
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.End),
) {
- dismissButton()
- Spacer(modifier = Modifier.width(16.dp))
+ if (dismissButton != null) {
+ dismissButton()
+ }
confirmButton()
}
}
}
}
+
+@Preview(widthDp = 800, heightDp = 400)
+@Composable
+private fun TextFieldDialogPreview() {
+ KeyMapperTheme {
+ TextFieldDialog(
+ title = "Title",
+ submitButtonText = "Submit",
+ initialText = "Some dialog text",
+ )
+ }
+}
+
+@Preview(widthDp = 800, heightDp = 400)
+@Composable
+private fun TextFieldDialogHintPreview() {
+ KeyMapperTheme {
+ TextFieldDialog(
+ title = "Title",
+ submitButtonText = "Submit",
+ initialText = "",
+ hint = "Some placeholder text",
+ )
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt
index bece2124d7..020f15b5a5 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt
@@ -6,51 +6,101 @@ import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListItemInfo
+import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
+/**
+ * The distance past the edge of the list at which auto scrolling reaches its maximum speed.
+ */
+private val autoScrollEdgeDistance = 64.dp
+
+/**
+ * Maximum auto scroll speed per second.
+ */
+private val autoScrollMaxSpeed = 1000.dp
+
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
/**
- * Ignore the last N items in the list. Do not allow dragging and dropping these items or
- * placing other items in these positions.
+ * The keys of the items that can be dragged, in their current order. Other items in the list,
+ * such as headers and footers, can not be dragged and items can not be dropped on them.
*/
- ignoreLastItems: Int = 0,
- onMove: (Int, Int) -> Unit,
+ keys: List,
+ /**
+ * Called once when the drag ends with the indices in [keys]. While dragging, the order is
+ * only changed locally, so use [DragDropState.ordered] to display the items.
+ */
+ onMove: (fromIndex: Int, toIndex: Int) -> Unit,
onStart: () -> Unit = {},
onEnd: () -> Unit = {},
): DragDropState {
val scope = rememberCoroutineScope()
+ val currentOnMove by rememberUpdatedState(onMove)
+ val currentOnStart by rememberUpdatedState(onStart)
+ val currentOnEnd by rememberUpdatedState(onEnd)
+
val state = remember(lazyListState) {
DragDropState(
state = lazyListState,
- ignoreLastItems = ignoreLastItems,
- onStart = onStart,
- onMove = onMove,
- onEnd = onEnd,
scope = scope,
+ onStart = { currentOnStart() },
+ onMove = { fromIndex, toIndex -> currentOnMove(fromIndex, toIndex) },
+ onEnd = { currentOnEnd() },
)
}
- LaunchedEffect(state) {
- while (true) {
- val diff = state.scrollChannel.receive()
- lazyListState.scrollBy(diff)
+ SideEffect {
+ state.updateKeys(keys)
+ }
+
+ val density = LocalDensity.current
+ val edgeDistancePx = with(density) { autoScrollEdgeDistance.toPx() }
+ val maxSpeedPx = with(density) { autoScrollMaxSpeed.toPx() }
+
+ LaunchedEffect(state, edgeDistancePx, maxSpeedPx) {
+ snapshotFlow { state.draggingItemKey != null }.collectLatest { isDragging ->
+ if (!isDragging) {
+ return@collectLatest
+ }
+
+ var lastFrameNanos = withFrameNanos { it }
+
+ while (true) {
+ val frameNanos = withFrameNanos { it }
+ val seconds = (frameNanos - lastFrameNanos) / 1_000_000_000f
+ lastFrameNanos = frameNanos
+
+ val overflow = state.draggingItemOverflow()
+ if (overflow != 0f) {
+ val fraction = (overflow / edgeDistancePx).coerceIn(-1f, 1f)
+ lazyListState.scrollBy(fraction * maxSpeedPx * seconds)
+ }
+
+ // Check again after scrolling because other items move under the dragged item.
+ state.moveToTarget()
+ }
}
}
@@ -58,20 +108,29 @@ fun rememberDragDropState(
}
/**
- * This is copied from an official demo for drag and drop at https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
+ * Originally based on the official demo for drag and drop at https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
+ *
+ * Items are tracked by their key rather than their index so the list can contain other items,
+ * such as headers and footers. The new order is kept locally until the drag ends so the list
+ * does not depend on the new order propagating back from a ViewModel while dragging.
*/
class DragDropState internal constructor(
private val state: LazyListState,
- private val ignoreLastItems: Int,
private val scope: CoroutineScope,
private val onStart: () -> Unit,
private val onMove: (Int, Int) -> Unit,
private val onEnd: () -> Unit,
) {
- var draggingItemIndex by mutableStateOf(null)
+ var draggingItemKey by mutableStateOf(null)
private set
- internal val scrollChannel = Channel()
+ private var keys: List = emptyList()
+
+ /**
+ * The order of the keys while dragging, and after the drag ends until the new order is
+ * received in [updateKeys].
+ */
+ private var pendingOrder by mutableStateOf?>(null)
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
@@ -81,44 +140,81 @@ class DragDropState internal constructor(
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
- get() = state.layoutInfo.visibleItemsInfo
- .firstOrNull { it.index == draggingItemIndex }
+ get() = draggingItemKey?.let { key ->
+ state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
+ }
+
+ /**
+ * The layout info when the order was last changed. Do not change the order again until the
+ * list has been laid out with the new order, otherwise the item can move back and forth.
+ */
+ private var layoutInfoAtLastMove: LazyListLayoutInfo? = null
- internal var previousIndexOfDraggedItem by mutableStateOf(null)
+ internal var previousKeyOfDraggedItem by mutableStateOf(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
- fun onDragStart(index: Int, offset: Offset) {
- // Calculate the offset of the item in the list
- val lazyItem = state.layoutInfo.visibleItemsInfo
- .firstOrNull { it.index == index }
- ?: return
+ /**
+ * Sort the items in the order they should be displayed while dragging.
+ */
+ fun ordered(items: List, key: (T) -> Any): List {
+ val order = pendingOrder ?: return items
+ val positions = order.withIndex().associate { it.value to it.index }
+ return items.sortedBy { positions[key(it)] ?: Int.MAX_VALUE }
+ }
+
+ internal fun updateKeys(newKeys: List) {
+ if (newKeys == keys) {
+ return
+ }
+
+ keys = newKeys
- val initialOffset = lazyItem.offset
+ // The moved list has been received so the local order is no longer needed.
+ if (draggingItemKey == null) {
+ pendingOrder = null
+ }
+ }
- val finalOffset = offset + Offset(0f, initialOffset.toFloat())
+ /**
+ * Start dragging the item with this key. Use this when dragging with a drag handle.
+ */
+ fun onDragStart(key: Any) {
+ if (key !in keys) {
+ return
+ }
- onDragStart(finalOffset)
+ val item = state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } ?: return
+ startDragging(item)
}
+ /**
+ * Start dragging the item at this offset in the list.
+ */
fun onDragStart(offset: Offset) {
- // check if the touch position is on drag handle
- state.layoutInfo.visibleItemsInfo
- .firstOrNull { item ->
- item.index < state.layoutInfo.totalItemsCount - ignoreLastItems &&
- offset.y.toInt() in item.offset..(item.offset + item.size)
- }?.also {
- draggingItemIndex = it.index
- draggingItemInitialOffset = it.offset
- }
+ val item = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
+ item.key in keys && offset.y.toInt() in item.offset..item.offsetEnd
+ } ?: return
+
+ startDragging(item)
+ }
+
+ private fun startDragging(item: LazyListItemInfo) {
+ pendingOrder = ordered(keys) { it }
+ draggingItemKey = item.key
+ draggingItemInitialOffset = item.offset
+ draggingItemDraggedDelta = 0f
+ layoutInfoAtLastMove = null
onStart.invoke()
}
fun onDragInterrupted() {
- if (draggingItemIndex != null) {
- previousIndexOfDraggedItem = draggingItemIndex
+ val key = draggingItemKey
+
+ if (key != null) {
+ previousKeyOfDraggedItem = key
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
@@ -129,68 +225,123 @@ class DragDropState internal constructor(
visibilityThreshold = 1f,
),
)
- previousIndexOfDraggedItem = null
+ previousKeyOfDraggedItem = null
+ }
+
+ val fromIndex = keys.indexOf(key)
+ val toIndex = ordered(keys) { it }.indexOf(key)
+
+ if (fromIndex != -1 && toIndex != -1 && fromIndex != toIndex) {
+ // Keep the local order until the new keys are received.
+ onMove.invoke(fromIndex, toIndex)
+ } else {
+ pendingOrder = null
}
}
+
draggingItemDraggedDelta = 0f
- draggingItemIndex = null
+ draggingItemKey = null
draggingItemInitialOffset = 0
+ layoutInfoAtLastMove = null
onEnd.invoke()
}
fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
+ moveToTarget()
+ }
+ /**
+ * Move the dragged item to the position of the item underneath its middle.
+ */
+ internal fun moveToTarget() {
val draggingItem = draggingItemLayoutInfo ?: return
+ val layoutInfo = state.layoutInfo
+ val order = pendingOrder ?: return
+
+ if (layoutInfo === layoutInfoAtLastMove) {
+ return
+ }
+
val startOffset = draggingItem.offset + draggingItemOffset
- val endOffset = startOffset + draggingItem.size
- val middleOffset = startOffset + (endOffset - startOffset) / 2f
+ val middleOffset = startOffset + draggingItem.size / 2f
+
+ val targetItem = layoutInfo.visibleItemsInfo.find { item ->
+ item.key != draggingItem.key &&
+ item.key in keys &&
+ middleOffset.toInt() in item.offset..item.offsetEnd
+ } ?: return
- val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
- middleOffset.toInt() in item.offset..item.offsetEnd &&
- draggingItem.index != item.index
+ // Only move once the middle of the dragged item has passed the middle of the target.
+ // Otherwise items of different heights swap back and forth.
+ val targetMiddle = targetItem.offset + targetItem.size / 2f
+ val hasPassedTarget = if (targetItem.offset > draggingItem.offset) {
+ middleOffset > targetMiddle
+ } else {
+ middleOffset < targetMiddle
}
- val itemCount = state.layoutInfo.totalItemsCount
- if (targetItem != null) {
- val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
- draggingItem.index
- } else if (draggingItem.index == state.firstVisibleItemIndex) {
- targetItem.index
- } else {
- null
- }
+ if (!hasPassedTarget) {
+ return
+ }
- if (draggingItem.index < itemCount - ignoreLastItems &&
- targetItem.index < itemCount - ignoreLastItems
- ) {
- if (scrollToIndex != null) {
- scope.launch {
- // this is needed to neutralize automatic keeping the first item first.
- state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
- onMove.invoke(draggingItem.index, targetItem.index)
- }
- } else {
- onMove.invoke(draggingItem.index, targetItem.index)
- }
- draggingItemIndex = targetItem.index
- }
+ val fromIndex = order.indexOf(draggingItem.key)
+ val toIndex = order.indexOf(targetItem.key)
+
+ if (fromIndex == -1 || toIndex == -1) {
+ return
+ }
+
+ // Where the dragged item will be laid out after moving if the other items stay in place.
+ val newOffset = if (targetItem.offset > draggingItem.offset) {
+ targetItem.offsetEnd - draggingItem.size
} else {
- val overscroll = when {
- draggingItemDraggedDelta > 0 ->
- (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(
- 0f,
- )
+ targetItem.offset
+ }
+ val minOffset = layoutInfo.viewportStartOffset
+ val maxOffset =
+ (layoutInfo.viewportEndOffset - draggingItem.size).coerceAtLeast(minOffset)
- draggingItemDraggedDelta < 0 ->
- (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
+ if (newOffset !in minOffset..maxOffset) {
+ // Moving a small item past a large item can move it out of the list, which stops
+ // it being laid out so the drag can not continue. Scroll so it stays in the list.
+ // The dragged item will be at the target's index after moving.
+ state.requestScrollToItem(targetItem.index, -newOffset.coerceIn(minOffset, maxOffset))
+ } else if (draggingItem.index == state.firstVisibleItemIndex ||
+ targetItem.index == state.firstVisibleItemIndex
+ ) {
+ // The list keeps the first visible item in place when the order changes, which
+ // scrolls the list when the first visible item is moved. Keep the scroll position.
+ state.requestScrollToItem(
+ state.firstVisibleItemIndex,
+ state.firstVisibleItemScrollOffset,
+ )
+ }
- else -> 0f
- }
- if (overscroll != 0f) {
- scrollChannel.trySend(overscroll)
- }
+ pendingOrder = order.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
+ layoutInfoAtLastMove = layoutInfo
+ }
+
+ /**
+ * How far the dragged item is past the edge of the list in the direction it is being dragged.
+ * Positive when past the end and negative when past the start.
+ */
+ internal fun draggingItemOverflow(): Float {
+ val draggingItem = draggingItemLayoutInfo ?: return 0f
+ val layoutInfo = state.layoutInfo
+
+ val startOffset = draggingItem.offset + draggingItemOffset
+ val endOffset = startOffset + draggingItem.size
+
+ return when {
+ draggingItemDraggedDelta > 0 ->
+ (endOffset - layoutInfo.viewportEndOffset).coerceAtLeast(0f)
+
+ draggingItemDraggedDelta < 0 ->
+ (startOffset - layoutInfo.viewportStartOffset).coerceAtMost(0f)
+
+ else -> 0f
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt
index d40e0cb3cd..da665d9cfd 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt
@@ -11,28 +11,39 @@ import androidx.compose.ui.zIndex
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
- index: Int,
+ /**
+ * The same key that is used for this item in the lazy list.
+ */
+ key: Any,
modifier: Modifier = Modifier,
content: @Composable BoxScope.(isDragging: Boolean) -> Unit,
) {
- val dragging = index == dragDropState.draggingItemIndex
+ val dragging = key == dragDropState.draggingItemKey
+
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = dragDropState.draggingItemOffset
}
- } else if (index == dragDropState.previousIndexOfDraggedItem) {
+ } else if (key == dragDropState.previousKeyOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = dragDropState.previousItemOffset.value
}
- } else {
+ } else if (dragDropState.draggingItemKey != null ||
+ dragDropState.previousKeyOfDraggedItem != null
+ ) {
+ // Only animate placement while a drag/drop is actually reflowing the list. Applying
+ // this unconditionally makes every row "jiggle" into place whenever the list is laid
+ // out for unrelated reasons, e.g. the pager swiping this page into view.
Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null,
)
+ } else {
+ Modifier
}
Box(modifier.then(draggingModifier)) {
content(dragging)
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt
new file mode 100644
index 0000000000..8b8fcbfc34
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt
@@ -0,0 +1,295 @@
+package io.github.sds100.keymapper.base.utils.ui.compose
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.DragHandle
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material.icons.rounded.KeyboardArrowUp
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.LookaheadScope
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+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
+
+const val EXPAND_ANIMATION_DURATION = 300
+
+/**
+ * The fade and size change share the same duration and easing so the content does not disappear
+ * before the card has finished changing shape.
+ */
+val expandTransition: EnterTransition =
+ fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) +
+ expandVertically(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.Top,
+ )
+
+val collapseTransition: ExitTransition =
+ fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) +
+ shrinkVertically(
+ animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing),
+ shrinkTowards = Alignment.Top,
+ )
+
+/**
+ * The header content is laid out in a row and is also in a lookahead scope so it can animate
+ * its bounds while the card expands.
+ */
+interface ExpandableDraggableCardHeaderScope :
+ RowScope,
+ LookaheadScope
+
+private class ExpandableDraggableCardHeaderScopeImpl(
+ rowScope: RowScope,
+ lookaheadScope: LookaheadScope,
+) : ExpandableDraggableCardHeaderScope,
+ RowScope by rowScope,
+ LookaheadScope by lookaheadScope
+
+/**
+ * A card that can be expanded by clicking its header and reordered by dragging its handle.
+ *
+ * @param key The key of this item in the drag and drop state.
+ * @param headerContent The content of the header row. It is always visible and is passed whether
+ * the card is expanded.
+ * @param expandedContent The content that is only visible when the card is expanded.
+ */
+@Composable
+fun ExpandableDraggableCard(
+ modifier: Modifier = Modifier,
+ key: Any,
+ isExpanded: Boolean,
+ isDragging: Boolean,
+ isReorderingEnabled: Boolean,
+ dragHandleContentDescription: String,
+ expandContentDescription: String,
+ collapseContentDescription: String,
+ isDraggingEnabled: Boolean = true,
+ dragDropState: DragDropState? = null,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ onExpandedChange: (Boolean) -> Unit = {},
+ onMoveUp: (() -> Unit)? = null,
+ onMoveDown: (() -> Unit)? = null,
+ headerContent: @Composable ExpandableDraggableCardHeaderScope.(isExpanded: Boolean) -> Unit,
+ expandedContent: @Composable () -> Unit,
+) {
+ val draggableState = rememberDraggableState {
+ dragDropState?.onDrag(Offset(0f, it))
+ }
+
+ val moveUpLabel = stringResource(R.string.accessibility_action_move_up)
+ val moveDownLabel = stringResource(R.string.accessibility_action_move_down)
+
+ val cardColors = CardDefaults.elevatedCardColors(
+ containerColor = if (isDragging) {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ } else {
+ MaterialTheme.colorScheme.surfaceContainer
+ },
+ contentColor = contentColor,
+ )
+
+ ElevatedCard(
+ modifier = modifier
+ .semantics {
+ if (isReorderingEnabled) {
+ customActions = buildList {
+ onMoveUp?.let { action ->
+ add(
+ CustomAccessibilityAction(moveUpLabel) {
+ action()
+ true
+ },
+ )
+ }
+ onMoveDown?.let { action ->
+ add(
+ CustomAccessibilityAction(moveDownLabel) {
+ action()
+ true
+ },
+ )
+ }
+ }
+ }
+ },
+ colors = cardColors,
+ ) {
+ LookaheadScope {
+ val lookaheadScope = this
+
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onExpandedChange(!isExpanded) },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Spacer(Modifier.width(8.dp))
+
+ if (isReorderingEnabled) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .draggable(
+ state = draggableState,
+ enabled = isDraggingEnabled,
+ orientation = Orientation.Vertical,
+ startDragImmediately = true,
+ onDragStarted = {
+ dragDropState?.onDragStart(key)
+ },
+ onDragStopped = { dragDropState?.onDragInterrupted() },
+ ),
+ imageVector = Icons.Rounded.DragHandle,
+ contentDescription = dragHandleContentDescription,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ val headerScope = remember(this, lookaheadScope) {
+ ExpandableDraggableCardHeaderScopeImpl(this, lookaheadScope)
+ }
+
+ headerScope.headerContent(isExpanded)
+
+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 16.dp,
+ ) {
+ IconButton(onClick = { onExpandedChange(!isExpanded) }) {
+ Icon(
+ imageVector = if (isExpanded) {
+ Icons.Rounded.KeyboardArrowUp
+ } else {
+ Icons.Rounded.KeyboardArrowDown
+ },
+ contentDescription = if (isExpanded) {
+ collapseContentDescription
+ } else {
+ expandContentDescription
+ },
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ Spacer(Modifier.width(4.dp))
+ }
+
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = expandTransition,
+ exit = collapseTransition,
+ ) {
+ expandedContent()
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CollapsedPreview() {
+ KeyMapperTheme {
+ ExpandableDraggableCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ key = "key",
+ isExpanded = false,
+ isDragging = false,
+ isReorderingEnabled = true,
+ dragHandleContentDescription = "Drag handle",
+ expandContentDescription = "Expand",
+ collapseContentDescription = "Collapse",
+ headerContent = {
+ Text(
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = 8.dp),
+ text = "Header",
+ )
+ },
+ expandedContent = {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp),
+ text = "Expanded content",
+ )
+ },
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ExpandedPreview() {
+ KeyMapperTheme {
+ ExpandableDraggableCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ key = "key",
+ isExpanded = true,
+ isDragging = false,
+ isReorderingEnabled = true,
+ dragHandleContentDescription = "Drag handle",
+ expandContentDescription = "Expand",
+ collapseContentDescription = "Collapse",
+ headerContent = {
+ Text(
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = 8.dp),
+ text = "Header",
+ )
+ },
+ expandedContent = {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp),
+ text = "Expanded content",
+ )
+ },
+ )
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt
new file mode 100644
index 0000000000..2c9405fcff
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt
@@ -0,0 +1,52 @@
+package io.github.sds100.keymapper.base.utils.ui.compose
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.dp
+
+/**
+ * The width of the shadow drawn over the start/end of a horizontally scrollable row to
+ * indicate that it can be scrolled.
+ */
+private val ScrollFadeEdgeWidth = 48.dp
+
+/**
+ * Draws a shadow over the start and/or end of the content to indicate it is horizontally
+ * scrollable in that direction. The shadow on each side is only drawn while it is possible to
+ * scroll further that way.
+ */
+fun Modifier.horizontalFadingEdges(scrollState: ScrollState): Modifier = this
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .drawWithContent {
+ drawContent()
+
+ val fadeWidthPx = ScrollFadeEdgeWidth.toPx()
+
+ if (scrollState.canScrollBackward) {
+ drawRect(
+ brush = Brush.horizontalGradient(
+ colors = listOf(Color.Transparent, Color.Black),
+ startX = 0f,
+ endX = fadeWidthPx,
+ ),
+ blendMode = BlendMode.DstIn,
+ )
+ }
+
+ if (scrollState.canScrollForward) {
+ drawRect(
+ brush = Brush.horizontalGradient(
+ colors = listOf(Color.Black, Color.Transparent),
+ startX = size.width - fadeWidthPx,
+ endX = size.width,
+ ),
+ blendMode = BlendMode.DstIn,
+ )
+ }
+ }
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt
index 8dc10f0f0b..839736e4c4 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt
@@ -8,6 +8,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonColors
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
@@ -27,6 +28,8 @@ import androidx.compose.ui.unit.sp
* @param onStateSelected Callback when a button is selected
* @param isCompact Whether to use compact styling (smaller shapes, auto-sizing text)
* @param isEnabled Whether the buttons are enabled
+ * @param isStateEnabled Whether an individual button is enabled. Defaults to always true; use
+ * this to disable specific options while leaving the rest of the row enabled.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -37,12 +40,16 @@ fun KeyMapperSegmentedButtonRow(
onStateSelected: (T) -> Unit,
isCompact: Boolean = false,
isEnabled: Boolean = true,
+ isStateEnabled: (T) -> Boolean = { true },
+ colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(),
) {
- val colors = if (isEnabled) {
- SegmentedButtonDefaults.colors()
+ val hasDisabledButton = !isEnabled || buttonStates.any { !isStateEnabled(it.first) }
+
+ val colors = if (!hasDisabledButton) {
+ colors
} else {
// The disabled border color of the inactive button is by default not greyed out enough
- SegmentedButtonDefaults.colors(
+ colors.copy(
disabledInactiveBorderColor =
SegmentedButtonDefaults.colors().inactiveBorderColor.copy(alpha = 0.5f),
)
@@ -54,15 +61,15 @@ fun KeyMapperSegmentedButtonRow(
for (content in buttonStates) {
val (state, label) = content
val isSelected = state == selectedState
- val isDisabled = !isEnabled
- val isUnselectedDisabled = isDisabled && !isSelected
+ val isButtonEnabled = isEnabled && isStateEnabled(state)
+ val isUnselectedDisabled = !isButtonEnabled && !isSelected
if (isCompact) {
SegmentedButton(
modifier = Modifier.height(36.dp),
selected = isSelected,
onClick = { onStateSelected(state) },
- enabled = isEnabled,
+ enabled = isButtonEnabled,
icon = { },
shape = SegmentedButtonDefaults.itemShape(
index = buttonStates.indexOf(content),
@@ -89,7 +96,7 @@ fun KeyMapperSegmentedButtonRow(
SegmentedButton(
selected = isSelected,
onClick = { onStateSelected(state) },
- enabled = isEnabled,
+ enabled = isButtonEnabled,
shape = SegmentedButtonDefaults.itemShape(
index = buttonStates.indexOf(content),
count = buttonStates.size,
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt
index 185f90c1f6..584dabb4ea 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -44,7 +45,7 @@ import kotlin.math.roundToInt
@Composable
fun SliderOptionText(
modifier: Modifier = Modifier,
- title: String,
+ title: String?,
value: Float,
defaultValue: Float,
valueText: (Float) -> String,
@@ -55,35 +56,43 @@ fun SliderOptionText(
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
+ var newSliderValue: Float? by remember { mutableStateOf(null) }
+ val currentValue = newSliderValue ?: value
+
if (showDialog) {
ValueDialog(
initialValue = if (value == defaultValue) {
null
} else {
- value.roundToInt()
+ currentValue.roundToInt()
},
placeholderValue = valueText(defaultValue),
title = title,
onDismissRequest = { showDialog = false },
onSaveClick = { newValue ->
+ newSliderValue = null
+
if (newValue == null) {
onValueChange(defaultValue)
} else {
onValueChange(newValue.toFloat())
}
+
showDialog = false
},
)
}
Column(modifier = modifier) {
- Text(
- modifier = Modifier,
- text = title,
- style = MaterialTheme.typography.titleSmall,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
+ if (title != null) {
+ Text(
+ modifier = Modifier,
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
Row(verticalAlignment = Alignment.CenterVertically) {
val interactionSource = remember { MutableInteractionSource() }
@@ -92,8 +101,13 @@ fun SliderOptionText(
Slider(
modifier = Modifier.weight(1f),
- value = value,
- onValueChange = onValueChange,
+ value = currentValue,
+ onValueChange = { newSliderValue = it },
+ onValueChangeFinished = {
+ newSliderValue?.let {
+ onValueChange(it)
+ }
+ },
enabled = isEnabled,
valueRange = valueRange,
interactionSource = interactionSource,
@@ -101,18 +115,21 @@ fun SliderOptionText(
KeyMapperSliderThumb(interactionSource)
},
steps =
- (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).toInt()) -
+ (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).roundToInt()) -
1,
)
Spacer(modifier = Modifier.width(8.dp))
- ElevatedButton(onClick = { showDialog = true }) {
+ ElevatedButton(
+ onClick = { showDialog = true },
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
+ ) {
// Do not show the text when dragging because it popping in/out moves the slider
- val text = if (value == defaultValue && !isDragged) {
- stringResource(R.string.slider_default_button, valueText(value))
+ val text = if (currentValue == defaultValue && !isDragged) {
+ stringResource(R.string.slider_default_button, valueText(currentValue))
} else {
- valueText(value)
+ valueText(currentValue)
}
Text(text)
@@ -120,8 +137,13 @@ fun SliderOptionText(
// Always show the reset button because it popping in/out when dragging over
// the default value causes the slider to change size and jiggle.
- AnimatedVisibility(visible = value != defaultValue || isDragged) {
- IconButton(onClick = { onValueChange(defaultValue) }) {
+ AnimatedVisibility(visible = currentValue != defaultValue || isDragged) {
+ IconButton(
+ onClick = {
+ newSliderValue = null
+ onValueChange(defaultValue)
+ },
+ ) {
Icon(
Icons.Rounded.RestartAlt,
contentDescription = stringResource(
@@ -138,7 +160,7 @@ fun SliderOptionText(
private fun ValueDialog(
initialValue: Int?,
placeholderValue: String?,
- title: String,
+ title: String?,
onDismissRequest: () -> Unit,
onSaveClick: (Int?) -> Unit,
) {
@@ -218,6 +240,26 @@ private fun Preview() {
}
}
+@Preview
+@Composable
+private fun PreviewNoTitle() {
+ KeyMapperTheme {
+ Surface {
+ SliderOptionText(
+ modifier = Modifier.width(400.dp),
+ title = null,
+ value = 50f,
+ defaultValue = 500f,
+ valueText = { "${it.roundToInt()} ms" },
+ isEnabled = true,
+ onValueChange = {},
+ valueRange = 0f..1000f,
+ stepSize = 50,
+ )
+ }
+ }
+}
+
@Preview
@Composable
private fun PreviewDefault() {
@@ -238,7 +280,7 @@ private fun PreviewDefault() {
}
}
-@Preview
+@Preview(showSystemUi = true)
@Composable
private fun PreviewDialog() {
KeyMapperTheme {
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt
index 3f124629ef..ea205c478e 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt
@@ -39,9 +39,9 @@ fun SwitchPreferenceCompose(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
Icon(
- modifier = Modifier.align(Alignment.CenterVertically),
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt
new file mode 100644
index 0000000000..6db29e607a
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt
@@ -0,0 +1,87 @@
+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.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+/**
+ * The same layout as [SwitchPreferenceCompose] but with a trailing settings cog that opens
+ * further configuration for the option, e.g. [io.github.sds100.keymapper.base.keymaps.TriggerVibrationBottomSheet].
+ */
+@Composable
+fun SwitchSettingsPreferenceCompose(
+ modifier: Modifier = Modifier,
+ title: String,
+ text: String?,
+ icon: ImageVector,
+ isChecked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ onSettingsClick: () -> Unit,
+ isEnabled: Boolean = true,
+ isSettingsEnabled: Boolean = true,
+) {
+ Surface(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.medium,
+ onClick = {
+ onCheckedChange(!isChecked)
+ },
+ enabled = isEnabled,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title, style = MaterialTheme.typography.bodyLarge)
+ if (text != null) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ IconButton(
+ onClick = onSettingsClick,
+ enabled = isSettingsEnabled,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Settings,
+ contentDescription = null,
+ )
+ }
+
+ Switch(
+ checked = isChecked,
+ onCheckedChange = onCheckedChange,
+ enabled = isEnabled,
+ )
+ }
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt
new file mode 100644
index 0000000000..2c0a7bc971
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt
@@ -0,0 +1,158 @@
+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.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import io.github.sds100.keymapper.base.R
+import io.github.sds100.keymapper.base.compose.KeyMapperTheme
+import io.github.sds100.keymapper.base.utils.VibrateEffectStrings
+import io.github.sds100.keymapper.base.utils.ui.SliderMaximums
+import io.github.sds100.keymapper.base.utils.ui.SliderMinimums
+import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+import kotlin.math.roundToInt
+
+enum class VibrationEffectMode {
+ DURATION,
+ PREDEFINED,
+}
+
+/**
+ * The content for picking a [VibrateEffect]: either a custom duration or a predefined haptic
+ * effect. This has no [androidx.compose.material3.ModalBottomSheet] or Done/Cancel buttons of
+ * its own so it can be embedded in different bottom sheets.
+ */
+@Composable
+fun VibrationEffectPickerContent(
+ modifier: Modifier = Modifier,
+ mode: VibrationEffectMode,
+ onModeChange: (VibrationEffectMode) -> Unit,
+ durationMs: Int,
+ onDurationChange: (Int) -> Unit,
+ defaultDurationMs: Int,
+ predefinedType: VibrateEffect.PredefinedType,
+ onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit,
+ isPredefinedSupported: Boolean = true,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ KeyMapperSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth(),
+ buttonStates = listOf(
+ VibrationEffectMode.DURATION to
+ stringResource(R.string.vibration_effect_mode_duration),
+ VibrationEffectMode.PREDEFINED to
+ if (isPredefinedSupported) {
+ stringResource(R.string.vibration_effect_mode_predefined)
+ } else {
+ stringResource(
+ R.string.vibration_effect_mode_predefined_unsuppored,
+ )
+ },
+ ),
+ selectedState = mode,
+ onStateSelected = onModeChange,
+ isStateEnabled = { it != VibrationEffectMode.PREDEFINED || isPredefinedSupported },
+ )
+
+ when (mode) {
+ VibrationEffectMode.DURATION -> {
+ val durationMin = SliderMinimums.VIBRATION_DURATION
+ val durationMax = SliderMaximums.VIBRATION_DURATION
+
+ SliderOptionText(
+ modifier = Modifier.fillMaxWidth(),
+ title = stringResource(R.string.extra_label_vibration_duration),
+ value = durationMs.toFloat(),
+ defaultValue = defaultDurationMs.toFloat(),
+ valueText = { "${it.toInt()} ms" },
+ onValueChange = { onDurationChange(it.roundToInt()) },
+ valueRange = durationMin.toFloat()..durationMax.toFloat(),
+ stepSize = SliderStepSizes.VIBRATION_DURATION,
+ )
+ }
+
+ VibrationEffectMode.PREDEFINED -> {
+ FlowRow(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ for (type in VibrateEffect.PredefinedType.entries) {
+ FilterChip(
+ selected = predefinedType == type,
+ onClick = { onPredefinedTypeChange(type) },
+ label = { Text(stringResource(VibrateEffectStrings.getLabel(type))) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(widthDp = 400)
+@Composable
+private fun VibrationEffectPickerContentDurationPreview() {
+ KeyMapperTheme {
+ Surface {
+ VibrationEffectPickerContent(
+ modifier = Modifier.fillMaxWidth(),
+ mode = VibrationEffectMode.DURATION,
+ onModeChange = {},
+ durationMs = 200,
+ onDurationChange = {},
+ defaultDurationMs = 200,
+ predefinedType = VibrateEffect.PredefinedType.CLICK,
+ onPredefinedTypeChange = {},
+ )
+ }
+ }
+}
+
+@Preview(widthDp = 300)
+@Composable
+private fun VibrationEffectPickerContentPredefinedPreview() {
+ KeyMapperTheme {
+ Surface {
+ VibrationEffectPickerContent(
+ modifier = Modifier.fillMaxWidth(),
+ mode = VibrationEffectMode.PREDEFINED,
+ onModeChange = {},
+ durationMs = 200,
+ onDurationChange = {},
+ defaultDurationMs = 200,
+ predefinedType = VibrateEffect.PredefinedType.CLICK,
+ onPredefinedTypeChange = {},
+ )
+ }
+ }
+}
+
+@Preview(widthDp = 400)
+@Composable
+private fun VibrationEffectPickerContentPredefinedUnsupportedPreview() {
+ KeyMapperTheme {
+ Surface {
+ VibrationEffectPickerContent(
+ modifier = Modifier.fillMaxWidth(),
+ mode = VibrationEffectMode.DURATION,
+ onModeChange = {},
+ durationMs = 200,
+ onDurationChange = {},
+ defaultDurationMs = 200,
+ predefinedType = VibrateEffect.PredefinedType.CLICK,
+ onPredefinedTypeChange = {},
+ isPredefinedSupported = false,
+ )
+ }
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt
new file mode 100644
index 0000000000..9daba1e40a
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt
@@ -0,0 +1,87 @@
+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.MobileSensorHi: ImageVector
+ get() {
+ if (_MobileSensorHi != null) {
+ return _MobileSensorHi!!
+ }
+ _MobileSensorHi = ImageVector.Builder(
+ name = "MobileSensorHi",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 960f,
+ viewportHeight = 960f,
+ ).apply {
+ path(fill = SolidColor(Color.Black)) {
+ moveTo(320f, 840f)
+ quadToRelative(-33f, 0f, -56.5f, -23.5f)
+ reflectiveQuadTo(240f, 760f)
+ verticalLineToRelative(-560f)
+ quadToRelative(0f, -33f, 23.5f, -56.5f)
+ reflectiveQuadTo(320f, 120f)
+ horizontalLineToRelative(320f)
+ quadToRelative(33f, 0f, 56.5f, 23.5f)
+ reflectiveQuadTo(720f, 200f)
+ verticalLineToRelative(560f)
+ quadToRelative(0f, 33f, -23.5f, 56.5f)
+ reflectiveQuadTo(640f, 840f)
+ lineTo(320f, 840f)
+ close()
+ moveTo(640f, 760f)
+ verticalLineToRelative(-560f)
+ lineTo(320f, 200f)
+ verticalLineToRelative(560f)
+ horizontalLineToRelative(320f)
+ close()
+ moveTo(508.5f, 308.5f)
+ quadTo(520f, 297f, 520f, 280f)
+ reflectiveQuadToRelative(-11.5f, -28.5f)
+ quadTo(497f, 240f, 480f, 240f)
+ reflectiveQuadToRelative(-28.5f, 11.5f)
+ quadTo(440f, 263f, 440f, 280f)
+ reflectiveQuadToRelative(11.5f, 28.5f)
+ quadTo(463f, 320f, 480f, 320f)
+ reflectiveQuadToRelative(28.5f, -11.5f)
+ close()
+ moveTo(0f, 680f)
+ verticalLineToRelative(-280f)
+ horizontalLineToRelative(80f)
+ verticalLineToRelative(280f)
+ lineTo(0f, 680f)
+ close()
+ moveTo(120f, 560f)
+ verticalLineToRelative(-280f)
+ horizontalLineToRelative(80f)
+ verticalLineToRelative(280f)
+ horizontalLineToRelative(-80f)
+ close()
+ moveTo(880f, 560f)
+ verticalLineToRelative(-280f)
+ horizontalLineToRelative(80f)
+ verticalLineToRelative(280f)
+ horizontalLineToRelative(-80f)
+ close()
+ moveTo(760f, 680f)
+ verticalLineToRelative(-280f)
+ horizontalLineToRelative(80f)
+ verticalLineToRelative(280f)
+ horizontalLineToRelative(-80f)
+ close()
+ moveTo(320f, 760f)
+ verticalLineToRelative(-560f)
+ verticalLineToRelative(560f)
+ close()
+ }
+ }.build()
+
+ return _MobileSensorHi!!
+ }
+
+@Suppress("ObjectPropertyName")
+private var _MobileSensorHi: ImageVector? = null
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt
new file mode 100644
index 0000000000..ececc2f0c8
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt
@@ -0,0 +1,121 @@
+package io.github.sds100.keymapper.base.vibration
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import dagger.hilt.android.scopes.ViewModelScoped
+import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectMode
+import io.github.sds100.keymapper.system.vibrator.VibratorAdapter
+import javax.inject.Inject
+
+private const val MAX_PREVIEW_VIBRATION_DURATION_MS = 1000L
+
+data class VibrateConfigState(
+ val mode: VibrationEffectMode = VibrationEffectMode.DURATION,
+ val durationMs: Int,
+ val defaultDurationMs: Int,
+ val predefinedType: VibrateEffect.PredefinedType = VibrateEffect.PredefinedType.CLICK,
+ val isPredefinedSupported: Boolean = true,
+)
+
+/**
+ * Shared UI logic for picking a [VibrateEffect] (a custom duration or a predefined haptic
+ * effect), used by both the Vibrate action and the key map "vibrate on trigger" option. It plays
+ * a short preview vibration whenever the mode, duration or predefined effect changes so the user
+ * can feel what they're configuring.
+ */
+interface VibrateConfigDelegate {
+ val vibrateConfigState: VibrateConfigState?
+
+ fun openVibrateConfig(effect: VibrateEffect?, defaultDurationMs: Int)
+ fun closeVibrateConfig()
+ fun onVibrateModeChange(mode: VibrationEffectMode)
+ fun onVibrateDurationChange(durationMs: Int)
+ fun onVibratePredefinedTypeChange(type: VibrateEffect.PredefinedType)
+
+ /**
+ * @return the [VibrateEffect] built from the current config state, or null if nothing is
+ * currently being configured.
+ */
+ fun buildVibrateEffect(): VibrateEffect?
+}
+
+@ViewModelScoped
+class VibrateConfigDelegateImpl @Inject constructor(private val vibratorAdapter: VibratorAdapter) :
+ VibrateConfigDelegate {
+
+ override var vibrateConfigState: VibrateConfigState? by mutableStateOf(null)
+ private set
+
+ override fun openVibrateConfig(effect: VibrateEffect?, defaultDurationMs: Int) {
+ val isPredefinedSupported = vibratorAdapter.supportsPredefinedEffects
+ val usePredefined = effect is VibrateEffect.Predefined && isPredefinedSupported
+
+ vibrateConfigState = VibrateConfigState(
+ mode = if (usePredefined) {
+ VibrationEffectMode.PREDEFINED
+ } else {
+ VibrationEffectMode.DURATION
+ },
+ durationMs = (effect as? VibrateEffect.CustomDuration)?.durationMs?.toInt()
+ ?: defaultDurationMs,
+ defaultDurationMs = defaultDurationMs,
+ predefinedType = (effect as? VibrateEffect.Predefined)?.predefinedType
+ ?: VibrateEffect.PredefinedType.CLICK,
+ isPredefinedSupported = isPredefinedSupported,
+ )
+ }
+
+ override fun closeVibrateConfig() {
+ vibrateConfigState = null
+ }
+
+ override fun onVibrateModeChange(mode: VibrationEffectMode) {
+ val currentState = vibrateConfigState ?: return
+
+ if (mode == VibrationEffectMode.PREDEFINED && !currentState.isPredefinedSupported) {
+ return
+ }
+
+ vibrateConfigState = currentState.copy(mode = mode)
+ playPreview()
+ }
+
+ override fun onVibrateDurationChange(durationMs: Int) {
+ vibrateConfigState = vibrateConfigState?.copy(durationMs = durationMs) ?: return
+ playPreview()
+ }
+
+ override fun onVibratePredefinedTypeChange(type: VibrateEffect.PredefinedType) {
+ vibrateConfigState = vibrateConfigState?.copy(predefinedType = type) ?: return
+ playPreview()
+ }
+
+ override fun buildVibrateEffect(): VibrateEffect? {
+ val state = vibrateConfigState ?: return null
+
+ return when (state.mode) {
+ VibrationEffectMode.DURATION ->
+ VibrateEffect.CustomDuration(state.durationMs.toLong())
+
+ VibrationEffectMode.PREDEFINED ->
+ VibrateEffect.Predefined(state.predefinedType)
+ }
+ }
+
+ private fun playPreview() {
+ val effect = buildVibrateEffect() ?: return
+
+ // Cap how long the preview buzzes for so a long custom duration doesn't play in full
+ // every time the user drags the slider.
+ val previewEffect = if (effect is VibrateEffect.CustomDuration) {
+ VibrateEffect.CustomDuration(
+ effect.durationMs.coerceAtMost(MAX_PREVIEW_VIBRATION_DURATION_MS),
+ )
+ } else {
+ effect
+ }
+
+ vibratorAdapter.vibrate(previewEffect)
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt
new file mode 100644
index 0000000000..3c33d6b816
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt
@@ -0,0 +1,20 @@
+package io.github.sds100.keymapper.base.vibration
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class VibrateEffect {
+ @Serializable
+ data class CustomDuration(val durationMs: Long) : VibrateEffect()
+
+ @Serializable
+ data class Predefined(val predefinedType: PredefinedType) : VibrateEffect()
+
+ @Serializable
+ enum class PredefinedType {
+ CLICK,
+ DOUBLE_CLICK,
+ HEAVY_CLICK,
+ TICK,
+ }
+}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt
new file mode 100644
index 0000000000..f0ba2ca0c8
--- /dev/null
+++ b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt
@@ -0,0 +1,28 @@
+package io.github.sds100.keymapper.base.vibration
+
+import io.github.sds100.keymapper.system.vibrator.PredefinedVibrationEffect
+import io.github.sds100.keymapper.system.vibrator.VibratorAdapter
+
+/**
+ * Fires [effect] on the vibrator, falling back to [defaultDurationMs] when [effect] is null.
+ */
+fun VibratorAdapter.vibrate(effect: VibrateEffect?, defaultDurationMs: Long) {
+ when (effect) {
+ null -> vibrate(defaultDurationMs)
+ else -> vibrate(effect)
+ }
+}
+
+fun VibratorAdapter.vibrate(effect: VibrateEffect) {
+ when (effect) {
+ is VibrateEffect.CustomDuration -> vibrate(effect.durationMs)
+ is VibrateEffect.Predefined -> vibrate(effect.predefinedType.toSystemEffect())
+ }
+}
+
+private fun VibrateEffect.PredefinedType.toSystemEffect(): PredefinedVibrationEffect = when (this) {
+ VibrateEffect.PredefinedType.CLICK -> PredefinedVibrationEffect.CLICK
+ VibrateEffect.PredefinedType.DOUBLE_CLICK -> PredefinedVibrationEffect.DOUBLE_CLICK
+ VibrateEffect.PredefinedType.HEAVY_CLICK -> PredefinedVibrationEffect.HEAVY_CLICK
+ VibrateEffect.PredefinedType.TICK -> PredefinedVibrationEffect.TICK
+}
diff --git a/base/src/main/res/values-pt/strings.xml b/base/src/main/res/values-pt/strings.xml
index dd4af2f2a9..84f438f8fe 100644
--- a/base/src/main/res/values-pt/strings.xml
+++ b/base/src/main/res/values-pt/strings.xml
@@ -553,7 +553,6 @@ Deixe em branco se alguma rede Wi-Fi precisar ser correspondida.
Segure firme
Mantenha pressionado até ser pressionado novamente
Não remapeie
- Permitir que outros aplicativos acionem este mapa de teclas
Copiar ID do mapa de chaves
⌨
diff --git a/base/src/main/res/values-tr/strings.xml b/base/src/main/res/values-tr/strings.xml
index f10bf2ee04..1e57fffdc8 100644
--- a/base/src/main/res/values-tr/strings.xml
+++ b/base/src/main/res/values-tr/strings.xml
@@ -547,7 +547,6 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz.
Basılı tut
Tekrar basılana kadar basılı tut
Yeniden eşleme yapma
- Diğer uygulamaların bu tuş eşlemesini intent\'ler veya kısayollarla kontrol etmesine izin ver
Tuş eşleme kimliğini kopyala
⌨
diff --git a/base/src/main/res/values-zh-rCN/strings.xml b/base/src/main/res/values-zh-rCN/strings.xml
index e4f0911123..320e264011 100644
--- a/base/src/main/res/values-zh-rCN/strings.xml
+++ b/base/src/main/res/values-zh-rCN/strings.xml
@@ -12,8 +12,8 @@
打开
关闭
跟随系统
- 此设备
- 任何设备
+ 这个安卓设备
+ 任何输入设备
默认
启用无障碍服务
重启无障碍服务
@@ -40,6 +40,13 @@
完成坐标选择
Key Mapper 日志
最新更新
+ 版本 %1$s
+ 新的
+ 改良
+ 修复
+ 查看完整更新日志
+ 在下面支持更多免费功能的开发\u00A0❤️
+ 感谢你支持 Key\u00A0Mapper\u00A0❤️
您可以选择系统铃声,也可以选择自定义音频文件。\n\n自定义音频文件将被复制到 Key Mapper 的私有数据文件夹中。这意味着即使原始文件被移动或删除,您的操作仍可正常触发。此外,该文件也会包含在按键映射备份的压缩包内,您可以在设置中删除已保存的音频文件
找不到任何已配对的设备。蓝牙开启了吗?
点击一个按键映射作为快捷键使用。
@@ -166,7 +173,7 @@
自动备份成功!
自动备份失败!
屏幕截图完成
- 屏幕截图分辨率与该设备的分辨率不匹配!
+ 截图的宽高比不同于这个设备的屏幕!
复制按键映射 UUID 到剪贴板
你已经触发了一个按键映射
日志已复制
@@ -229,6 +236,12 @@
物理朝向:横向
物理:纵向(倒置)
物理:横向(反向)
+ 显示分辨率
+ 分辨率是 %1$d × %2$d
+ 显示分辨率
+ 自定义
+ 宽度
+ 高度
应用正在播放媒体
应用没有播放媒体
正在播放媒体
@@ -259,12 +272,17 @@
正在通话
未在通话
设备正在响铃
+ 铃声模式:响铃
+ 铃声模式:振动
+ 铃声模式:静音
正在充电
未在充电
折叠轴关闭
折叠轴开启
折叠轴已被关闭
折叠轴已被开启
+ 正在显示通知面板
+ 未正在显示通知面板
纵向(0°)
横向(90°)
纵向(180°)
@@ -366,9 +384,11 @@
未连接外部设备。
禁用电池优化
您必须阅读此全部,否则您将来会沮丧!\n\n点击“部分修复”可能 防止 Android 在后台停止应用程序。\n\n这还不够。 您的 OEM 厂商 (例如 MIUI 或 Samsung Experience) 可能具有其他应用程序终止功能,因此您必须按照 dontkillmyapp.com 上的在线指南为 Key Mapper 关闭它们。
+ 授权所有文件访问
+ Key Mapper 需要“所有文件访问”来显示从另一个设备复制到这里的备份文件,因为 Android TV 没有内置的文件选择器。仅在 F-Droid 构建中请求这个权限,因为 Google Play 限制它仅供文件管理器使用。
通过打开和关闭重新启动无障碍服务,以便您可以测试该操作。
- Key Mapper已终止
- Key Mapper 曾尝试在后台运行,但被系统终止。\n这通常是因为您开启了电池或内存优化。\n\n要解决此问题,您可以尝试参考在线指南。完成设置后,您还应重新启动服务
+ 无障碍服务需要重启
+ 系统停止了 Key Mapper 的无障碍服务。\n\n要修复这个,关闭再打开无障碍服务来重启它。
继续
忽略
错误报告生成失败
@@ -382,13 +402,15 @@
授予 “勿扰” 权限
您将被带到设备的设置页面,以管理哪些应用程序可以修改 “请勿打扰” 状态。此功能在某些设备上可能不存在,因此如果您在列表中未看到“键映射器”,请点击 “不再显示”。
排序方式
- 拖动手柄可调整优先级。最上面的项目是最重要的。您还可以轻点任何项目来颠倒其排序顺序。
- 例如:要主要按升序排列的操作及其触发器按降序排列的键映射,请将“操作”移到第一位,将“触发器”移到第二位。
+ 拖动手柄以调整优先级。顶部的项目是最重要的。你必须点击项目以启用排序和切换上升/下降。
+ 示例:排序为操作第一(上升)和触发器第二(下降):轻触和拖动操作到第一的位置并且设置它为上升,然后点击和拖动触发器到第二的位置并且设置它为下降。
拖动 %1$s 的滑块
+ 上移
+ 下移
显示示例
启用通知
某些操作和选项需要此权限才能运行。您还可以在有关于应用程序的重要信息时收到通知。
- 迁移此屏幕关闭触发器
+ 迁移这个熄屏触发器
很抱歉造成不便,我们推出了一项名为“专家模式”的*免费*功能,用来取代原有的的熄屏映射选项。该功能更加可靠,且支持了电源键映射。\n\n由于其工作原理不同,您需要使用专家模式重新录制此触发器。
继续
授予 READ_LOGS 权限
@@ -407,6 +429,7 @@
已了解
开启
继续
+ 授权访问
关闭
取消
不再显示
@@ -493,6 +516,21 @@
分享 Logcat 日志
分享完整系统日志
分享 Logcat 日志失败
+ 专家模式调试
+ 查看原始输入设备信息和事件
+ 专家模式调试
+ 专家模式需要用到 getevent。开启专家模式然后回到这个屏幕。
+ 记录
+ 停止记录
+ 清除输出
+ 刷新
+ 正在载入设备信息\u2026
+ 复制到剪贴板
+ 保存到文件
+ 信息
+ 事件
+ 停止记录后出现输出。
+ 点击下面的记录按钮来记录事件。所有按键映射会临时暂停。
反馈问题
删除音效文件
删除可用于声音操作的声音文件。
@@ -506,6 +544,8 @@
3. Key Mapper 会自动使用 Shizuku。点击查看哪些 Key Mapper 功能使用 Shizuku。
更改默认选项
适用于触发器与动作
+ 默认不重新映射
+ 默认情况新的触发按键不会消耗按键事件
重置所有
危险!
确定要将应用中的所有设置重置为默认值吗?您现有的按键映射不会受到影响,功能介绍和警告弹窗将重新显示
@@ -534,9 +574,6 @@
亮色
暗色
系统
- 语言
- 选择应用语言
- 跟随系统
显示音量对话框
@@ -553,7 +590,6 @@
按住
保持直到再次按下
不要重映射
- 允许其他应用通过 Intent 或快捷方式控制此按键映射
复制按键映射 id
⌨
@@ -608,6 +644,7 @@
包名为 %s 的应用未安装!
应用 %s 已禁用!
您需要授予 Key Mapper 修改系统设置的权限。
+ Key Mapper 需要“所有文件访问”权限。
这需要 root 权限!
此操作需要相机权限!
需要 Android %s 或更新版本
@@ -639,6 +676,7 @@
无前置闪光灯
无后置闪光灯
手电筒不支持可变亮度
+ 您的设备暂不支持黑夜模式
无障碍服务需要启用!
无障碍需要重启!
您的启动器不支持快捷方式。
@@ -649,6 +687,10 @@
全局动作 %s 执行失败!
此操作需要配置
找不到电池优化设置,如果该设置项存在,请手动将其打开
+ 在列表中找到 Key Mapper 并为它禁用电池优化
+ 转到特殊应用权限 -> 电池优化 -> 禁用 Key Mapper
+ 转到设置 -> 特殊应用权限 -> 电池优化 -> 禁用 Key Mapper
+ 在这个设备上的设置页面未找到“所有文件访问”。
找不到额外 (%s) !
不能有重复的条件!
不能为空!
@@ -656,6 +698,7 @@
空的 SON 文件!
文件访问被拒绝!%s
未知 IO 错误!
+ 存储不可用。请稍后再试。
已取消
无效的数字!
必须至少为 %s!
@@ -687,6 +730,8 @@
需要打开%s!
切换输入法失败!
启用输入法失败!
+ 循环键盘语言失败!
+ 当前键盘没有启用多语言!
此设备无相机应用!
此设备无助理!
您的设备没有设置!
@@ -709,6 +754,7 @@
拒绝了查看配对蓝牙设备的权限!
拒绝了显示通知的权限!
读取日志权限已被拒绝!
+ 已拒绝访问本地网络的权限!
必须是 2 或以上!
必须是 %d 或以下!
必须大于 0!
@@ -803,11 +849,19 @@
停止一个应用的媒体
停止 %s 的媒体
步进媒体
+ 媒体前进 (%ds)
步进一个应用的媒体
步进 %s 的媒体
+ 媒体前进 %1$s (%2$ds)
步退媒体
+ 媒体后退 (%ds)
步进一个应用的媒体
步进 %s 的媒体
+ 媒体后退 %1$s (%2$ds)
+ 自定义进退数量
+ 进退
+ %d 秒
+ 不是所有应用支持自定义进退数量。某些应用会使用它们的固定跳过时长。
返回
主屏幕
打开最近任务
@@ -861,12 +915,15 @@
显示键盘
隐藏键盘
显示键盘选择器
+ 键盘输入/发送
切换键盘
切换到 %s
+ 循环键盘语言
剪切
复制
粘贴
在光标处选择单词
+ 选择所有文本
打开设置
显示电源菜单
切换飞行模式
@@ -901,7 +958,7 @@
播放声音
忽略多数最近的通知
清除所有通知
- 创建通知
+ 显示通知
显示通知:%1$s
通知标题
请输入通知标题
@@ -915,6 +972,14 @@
测试
正在测试按键中……
通知已成功显示
+ 显示屏幕消息
+ 显示短的消息:%1$s
+ 显示长的消息:%1$s
+ 吐司消息
+ 输入吐司消息
+ 消息不能为空
+ 短整形
+ 长整型
设备控制屏幕
HTTP 请求
HTTP 方法
@@ -989,6 +1054,68 @@
强制停止应用
关闭并从最近任务中清除应用
修改设置
+ TalkBack 手势
+ TalkBack: %s
+ 选择 TalkBack 手势
+ %1$s (%2$s)
+ 1-手指手势
+ 2-手指手势
+ 3-手指手势
+ 4-手指手势
+
+ 激活焦点项目
+ 移动朗读控制向上或后退
+ 移动朗读控制向下或前进
+ 上一个项目
+ 下一个项目
+ 上一个朗读控制
+ 下一个朗读控制
+ 向后滚动
+ 向前滚动
+ 开始语音命令
+ 暂停或恢复朗读
+ 播放或暂停媒体,接听或结束电话
+ 开始或停止选择模式
+ 从聚焦的项目朗读
+ 打开或关闭朗读
+ 打开 TalkBack 菜单
+ 屏幕搜索
+ 点击来分配
+ 上一个朗读控制
+ 下一个朗读控制
+ 练习手势
+ 打开教程
+ 上一个窗口
+ 下一个窗口
+ 上一个容器
+ 下一个容器
+
+ 双击
+ 上滑
+ 下滑
+ 左滑
+ 右滑
+ 上划然后向下
+ 下划然后向上
+ 左划然后向右
+ 右划然后向左
+ 右划然后向上
+ 用2个手指点击
+ 用2个手指双击
+ 用2个手指双击并按着
+ 用2个手指三击
+ 用2个手指三击并按着
+ 用3个手指点击
+ 用3个手指点击并按着
+ 用3个手指三击并按着
+ 用3个手指上划
+ 用3个手指下划
+ 用4个手指点击
+ 用4个手指双击
+ 用4个手指上划
+ 用4个手指下划
+ 用4个手指左划
+ 用4个手指右划
键名
键值
设置的键名不能为空
@@ -996,7 +1123,7 @@
注意:仅修改设置值可能不足以使更改生效,某些设置需要额外的操作或广播才能被系统处理
测试
设置修改成功
- 授权
+ 授予权限
选择设置项
没有找到设置
选择已有设置项
@@ -1083,6 +1210,8 @@
此应用的开源许可
隐私政策
正如隐私政策所说我们不会收集任何私人信息
+ 客户 ID
+ 已复制客户 ID
我们的团队
开发人员
用户体验设计师
@@ -1091,21 +1220,55 @@
译者(西班牙语)
- 支持键映射器❤️
- 选择插件以升级您的体验
- 您的支持让键映射器更进一步!
- "悬浮按钮功能是个重大改进"
+ 支持 Key\u00A0Mapper\u00A0❤️
+ 受到上千游戏玩家和爱好者的喜爱
+ 谢谢你!
+ 你购买了浮动按钮和助手触发器。
+ Key Mapper 在很大程度上是开源的,并且因为有参与贡献的人,它才能保持开源状态。我们宁愿提供真正对你有帮助的可选购买,也不愿在应用中充斥广告或植入收集你数据的跟踪器。
+ 我们很乐意帮助你解决任何问题和学习怎样使用功能。
+ 在 Discord 中寻求帮助
+ 观看浮动按钮视频
+ 给我们发送电子邮件
+ 你的支持让 Key Mapper 保持活力
Google Play 用户评论
悬浮按钮功能简直是神来之笔!
- 我一直很喜欢悬浮按钮这个功能
+ 浮动按钮是值得的!
加入悬浮按钮真是个明智之举
它已成为我手机使用体验中不可或缺的一部分
+ 我喜欢浮动按钮功能。
一次付费,永久解锁
观看视频
- 立即购买 (%s)
+ 只需 %s 即可解锁
更多信息
屏幕悬浮按钮
- 设备按键不够用?键映射器可以在任何应用、游戏或锁屏界面上,即刻创建快捷方式与宏命令!
+ 你的屏幕,你的控制—玩游戏和日常工作流在一个插件里。
+
+ 在游戏中起作用:
+ 在你玩游戏时保持你最喜爱的操作在一指之远。
+ 在锁屏上起作用:
+ 无需解锁手机访问快捷方式。
+ 自定义放置+外观:
+ 拖到你想要的位置,调整尺寸和不透明度。
+ 更多插件
+ 免费试用。\
+ 联系我们
+ \ 如果你不满意。
+ 奖励:包括了助手触发器
+ 或给我们买咖啡和午餐。所有选项解锁所有东西。
+ 给我们买咖啡或午餐。所有选项解锁所有东西。
+ 小费罐
+ 恢复购买
+ 已经购买?
+ 还原
+ 咖啡
+ 午餐
+ ☕️ 咖啡 · %1$s
+ ☕️ 咖啡 · %1$s (给了 %2$d)
+ 🥪 午餐 · %1$s
+ 🥪 午餐 · %1$s (给了 %2$d)
+ 购买套装 (%1$s)
+ 给我们买咖啡或午餐
+ 隐藏
侧键与助手触发器
侧键暂时空闲?可以将助手按键或设备的侧键快捷方式重新映射为您想要的任何功能!
关闭屏幕
@@ -1134,15 +1297,24 @@
Google Play 遇到错误。
我们正在等待 Google Play 确认您的购买是否成功。请于银行卡扣款成功后,重新打开 Key\u00A0Mapper 应用。
无效的购买。
+ 无法验证购买。您有互联网连接吗?
待付款
出错了 😕
重试
联系开发者
+ 你的购买已恢复!
+ 未找到购买信息
+ 这个设备上登录的 Google 账户没有 Key Mapper 购买信息。检查你登录的账户与购买 Key Mapper 的账户相同。\n\n如果你已购买,请联系我们并提供从 Google Play 购买确认信息邮件中得到的订单 ID。你可以在关于屏幕上找到客户 ID。
您必须购买侧键触发功能!请点击按键映射,然后前往商店购买
您必须购买悬浮按钮功能!请点击按键映射,然后前往商店购买
- 感谢您对本应用的支持!
- 购买成功!作为键映射器的付费用户,您将获得优先支持以帮助您使用本应用。现在商店中已增加一个联系开发者的按钮
高级触发器属于付费功能,但您下载的键映射器开源版本 (FOSS) 不包含此闭源模块或 Google Play 结算功能,请从 Google Play 下载键映射器以使用该功能。
+ 浮动按钮和助手触发器在这个自由开源构建中不可用。\n\n你仍可以在 Ko-fi 上支持开发。
+ 如果你捐赠并且后来在另一个设备上得到 Google Play,\
+ 联系我们
+ \ 我们会给你付费的功能,这样你不需要再次付钱。
+ 你的支持帮助保持 Key Mapper 大程度开源,没有广告或追踪。我们不售卖你的个人数据。
+ 捐款有助于资助 Android 兼容性更新、错误修复和维护、改进和文档编制,以及保持项目的可持续性。
+ 在 Ko-Fi 上支持
下载 Play 版本
按钮未被检测到?
@@ -1190,6 +1362,9 @@
铃声模式动作
建议在执行铃声模式动作时使用专家模式,以避免与“请勿打扰”设置冲突
使用专家模式
+ 改用专用操作
+ 这个不会如你所想那样工作。改用专用动作\"%1$s\"。
+ 覆盖操作
仅限特定应用使用?
在“约束”选项卡中添加约束条件
选择一个布局
@@ -1209,6 +1384,7 @@
动作
排除
选项
+ 已启用
按键映射
悬浮按钮
新建按键映射
@@ -1218,7 +1394,7 @@
没有足够的按钮?
您自己来制作!
悬浮按钮可以显示在您需要的应用程序之上。它们就像实体按钮一样工作,并且您可以随心所欲地放置、美化、映射它们。
- 隐藏此页面
+ 隐藏
感谢您对 Key\u00A0Mapper\u00A0❤️ 的支持!
创建您的第一个布局!
布局功能可助您将悬浮按钮分组管理,但不会影响按钮的显示状态。任何布局中的按钮均可同时显示在屏幕上。
@@ -1237,12 +1413,13 @@
是的,删除
取消
隐藏悬浮布局
- 在创建触发器时,您可以在“商店”按钮中找到悬浮按钮
+ 在创建一个触发时你可以在商店按钮中找到浮动按钮。
悬浮按钮
菜单
排序
更多
帮助
+ 最新更新
全选
取消全选
停止选择
@@ -1255,6 +1432,7 @@
设置
删除群组
关于
+ 报告问题
导出全部
导入
选择键盘
@@ -1262,6 +1440,10 @@
导入成功!
导出中…
导出失败: %s
+ 已保存 %s 到 Downloads
+ 选择一个备份
+ 在 Downloads 中未找到之前的 Key Mapper 备份。先在这个设备导出一个备份,然后它会在这里显示。
+ 授权来查看其他应用的文件
加载文件…
导入成功!
导入中…
@@ -1344,6 +1526,7 @@
是的,删除
取消
触发按键映射时运行这些操作:
+ 重复间隔小于 20 毫秒可能会卡顿!如果卡顿请调整时间。
选择侧边
亮度
最小
@@ -1390,7 +1573,7 @@
使用专家模式(Expert Mode)重新映射按键具有一定的危险性,如果映射不当,可能会导致按键失效。\n\n如果您操作失误,可能需要强制重启设备,方法通常是长按电源键和音量键 30 秒——请查阅您的设备手册或通过网络搜索了解具体操作方法
%d……
我已知悉
- 已了解
+ 明白了
设置
已检测到 Root 权限
通过授予键映射器Root 权限,您可以跳过设置过程,这还能让键映射器自动开启专家模式,无需等待 WiFi 连接
@@ -1413,11 +1596,15 @@
获取命令
重试
获取命令失败,请重试
+ 开启专家模式失败
+ 调试桥 adb 无法启动。请重试,如果问题仍然存在,请联系开发者 contact@keymapper.app。
系统桥接启动命令
将命令复制到剪贴板
自启动与保持后台运行
每当您重启设备或应用意外退出时,专家模式都会自动启动
无论是在设备开机还是程序意外退出时,“专家模式”都会自动启动,不过这需要获取 WRITE_SECURE_SETTINGS 权限
+ 重新映射了电源按钮紧急停止
+ 如果重新映射了电源按钮,长按电源按钮 10 秒并释放它来停止专家模式。如果电源按钮故障并且意外停止专家模式,关闭它。
按键事件动作
选择按键事件动作的执行方式
紧急提示
@@ -1446,17 +1633,42 @@
允许通知
Key Mapper 需要获取通知权限,以便在设置过程中出现问题时及时提醒您
授予权限
+ 允许访问本地网络
+ Key Mapper 需要访问本地网络来连接在你的设备上的 ADB 服务。
+ 授予权限
USB 配置不兼容
您必须将默认 USB 配置设置为“不进行数据传输”,以防止“专家模式”在每次锁屏时被强行关闭
“USB 调试(安全设置)”已禁用
您需要在“开发者选项”中开启“USB 调试(安全设置)”,专家模式才能正常工作,这通常是小米等设备的必要操作
打开开发者选项
+ 减少杀死应用
+ 小米设备 (MIUI/HyperOS) 积极关闭后台应用。应用少量设置来保持专家模式运行。
+ 减少杀死应用
+ 小米设备 (MIUI/HyperOS) 积极关闭后台应用并撤销权限。这些设置帮助保持专家模式运行。系统更新后你可能需要重新应用它们。
+ 启动专家模式来更改这个设置。
+ 出错了。确保专家模式正在运行并再试一次。
+ 在这个设备不能打开设置屏幕。
+ 已应用
+ 电池修复
+ 从电池优化白名单本应用并允许它在后台运行。应用这些是安全的。
+ 应用电池修复
+ MIUI 优化
+ 禁用 MIUI 优化是最有效的修复,但它可以更改某些 MIUI 功能的行为。
+ 禁用 MIUI 优化
+ 你可能需要重启你的设备来使它完全生效。再次关闭它来逆转。
+ 打开
+ 启用自动启动
+ 允许本应用自动启动,这样在设备重启之后它可以运行。
+ 省电模式
+ 设置本应用的省电模式为“无限制”,这样它在后台不会被关闭。
+ 在最近应用中锁定本应用
+ 打开最近应用屏幕,然后在本应用的卡片向下滑动(或者长按它)并点击锁定。清除所有时不会关闭它。
设置专家模式助手
专家模式正在运行
您现在可以在熄屏状态下重映射按键,并使用更多功能
完成专家模式配置
开启开发者选项
- 连续点击手机系统设置中的版本号五次以开启开发者模式
+ 重复点击设备版本号以打开开发者模式
自动配对
正在搜索配对码和端口……
未找到搜索配对码和端口
@@ -1477,7 +1689,7 @@
专家模式有什么用?
📲 您可以映射更多按键,例如电源键。\n⌨️ 在使用“按键代码 (Key Code)”动作时,可配合任何屏幕键盘使用。 \n⭐️ 解锁并使用更多高级动作。
显示专家模式信息
- 忽略
+ 关闭
专家模式意外停止
正在自动重启……
由于距离上次自动启动不到 5 分钟,将不再自动重启,如果您没有手动强杀服务,请向开发者报告此问题
@@ -1502,8 +1714,8 @@
锁屏
提升您的使用体验
悬浮按钮可在任何应用、游戏或锁定屏幕上作为即时快捷方式使用,您的支持是键映射器持续开发的动力 ❤️
- 查看演示
- 忽略
+ 看它怎样工作
+ 关闭
只需 %s 即可解锁
@@ -1578,6 +1790,12 @@
你喜欢悬浮按钮功能吗?
我们很想知道您的想法!请考虑在 Google Play 上留下评价,以帮助其他人发现此功能。❤️
- 忽略
+ 关闭
写入 evdev 事件失败
+ 报告问题
+ 我们希望你把错误报告到 Discord 服务器,使其他用户可以帮助你。否则,你可以用邮件联系我们 contact@keymapper.app。
+ 打开 Discord
+ 给我们发送电子邮件
+ 禁用自动切换键盘?
+ 在这里选择一个键盘会禁用自动切换键盘功能。你可以在设置里重新启用它。
diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml
index bb6384e0b9..5f7a574208 100644
--- a/base/src/main/res/values/strings.xml
+++ b/base/src/main/res/values/strings.xml
@@ -50,6 +50,13 @@
Key Mapper log
What\'s New
+ Version %1$s
+ New
+ Improved
+ Fixed
+ View full changelog
+ Feeling generous? Support development of more free features below\u00A0❤️
+ Thank you for supporting Key\u00A0Mapper\u00A0❤️
You can either use a system ringtone or select a custom sound file.\n\nThe custom sound file will be copied to Key Mapper\'s private data folder, which means your actions will still work even if the file is moved or deleted. It will also be included in backups with your key maps in the zip file.
You can delete saved sound files in the settings.
@@ -63,7 +70,8 @@
Reset
Once you have enabled the device admin, you must DEACTIVATE it if you want to uninstall Key Mapper.
- Wait %sms
+ Wait %sms
+ Wait %ss
Start activity: %s
Start service: %s
@@ -114,6 +122,7 @@
Options:
Actions:
Trigger:
+ None
Constraints:
Swipe up
Swipe down
@@ -232,6 +241,7 @@
%s is not in foreground
%s is playing media
%s is not playing media
+ Media is not playing
%s is connected
%s is disconnected
Screen is on
@@ -256,14 +266,25 @@
Landscape (270°)
Portrait (any)
Landscape (any)
+ Not portrait (0°)
+ Not landscape (90°)
+ Not portrait (180°)
+ Not landscape (270°)
+ Not portrait (any)
+ Not landscape (any)
Screen orientation
Physical orientation
Physical: Portrait
Physical: Landscape
Physical: Portrait (upside down)
Physical: Landscape (inverted)
+ Physical: Not portrait
+ Physical: Not landscape
+ Physical: Not portrait (upside down)
+ Physical: Not landscape (inverted)
Display resolution
Resolution is %1$d × %2$d
+ Resolution is not %1$d × %2$d
Display resolution
Custom
Width
@@ -301,11 +322,17 @@
Lock screen is not showing
In phone call
+ Ringing or not in call
Not in phone call
+ Ringing or in call
Phone ringing
+ Not ringing
Ringer mode: Ring
Ringer mode: Vibrate
Ringer mode: Silent
+ Ringer mode is not: Ring
+ Ringer mode is not: Vibrate
+ Ringer mode is not: Silent
Charging
Discharging
@@ -313,6 +340,8 @@
Hinge open
Hinge is closed
Hinge is open
+ Hinge is not closed
+ Hinge is not open
Notification panel is showing
Notification panel is not showing
@@ -322,8 +351,42 @@
Portrait (180°)
Landscape (270°)
+
+
+ Notification is posted
+ Notification from %s
+ No notification from %s
+ Notification %1$s %2$s \"%3$s\"
+ No notification %1$s %2$s \"%3$s\"
+
+ app
+ title
+ text
+
+ App
+ Title
+ Text
+
+ contains
+ is exactly
+
+ Contains
+ Exactly
+
+
+
+ Edit constraint
+ Notification constraint
+ Match on
+ How to match
+ App
+ Choose app
+ Notification title
+ Notification text
+ Some notifications may not match if the device is hiding sensitive notification content, such as on the lock screen.
Time
Time between %s and %s
+ Time not between %s and %s
Time constraint
Start time
Edit start time
@@ -344,7 +407,6 @@
https://keymapper.app/contributing/#translating?utm_source=in_app
https://github.com/keymapperorg/KeyMapper/blob/master/PRIVACY_POLICY.md
https://discord.gg/Suj6nyw
- https://keymapper.app/redirects/trigger-by-intent
https://keymapper.app/redirects/trigger
https://keymapper.app/redirects/trigger-options
https://keymapper.app/redirects/action
@@ -429,9 +491,6 @@
Add constraint
Choose Key code
Add extra
- Create launcher shortcut
- Create the shortcut manually
- Intent guide
Help
Select screenshot (optional)
Choose activity
@@ -483,7 +542,7 @@
Failed to create bug report
- Fix error
+ Fix errors
You have no files app installed that allows you to create a file for Key Mapper. Please install a file manager.
You have no files app installed that allows you to choose a file for Key Mapper. Please install a file manager.
@@ -750,7 +809,6 @@
Hold down
Hold down until pressed again
Do not remap
- Allow other apps to control this key map with intents or shortcuts
Copy key map id
⌨
@@ -815,6 +873,7 @@
You must be using the Key Mapper Input Method for this action to work!
The app with package name %s isn\'t installed!
+ %s isn\'t installed!
App %s is disabled!
You need to grant Key Mapper permission to modify system settings.
@@ -835,7 +894,7 @@
Key Mapper needs to be a device administrator!
Key Mapper doesn\'t have permission to use that shortcut
The app needs permission to change the Do Not Disturb state!
- This action needs permission to read the phone state!
+ Permission needed to read the phone state!
Can\'t find the WRITE_SETTINGS permission page!
Error opening this app shortcut
Can\'t find the Do Not Disturb access permission settings!
@@ -1135,6 +1194,7 @@
Switch keyboard
Switch to %s
Cycle keyboard language
+ Cycle through keyboards
Cut
Copy
@@ -1180,7 +1240,7 @@
Play sound
Dismiss most recent notification
Dismiss all notifications
- Create notification
+ Show notification
Show notification: %1$s
Notification title
Enter notification title
@@ -1194,7 +1254,7 @@
Test
Testing…
Notification shown successfully
- Show toast
+ Show on-screen message
Show short toast: %1$s
Show long toast: %1$s
Toast message
@@ -1202,6 +1262,16 @@
Message cannot be empty
Short
Long
+ Vibrate
+ Vibrate for %1$s ms
+ Vibrate: %1$s
+ Duration
+ Effect
+ Effect (unsupported)
+ Click
+ Double click
+ Heavy click
+ Tick
Device controls screen
HTTP request
HTTP Method
@@ -1387,6 +1457,7 @@
Phone
Power
Device
+ Notifications
Time
@@ -1580,7 +1651,7 @@
Please fill the following information so I can help you.\n\n1. Device model: %s\n2. Android version: %s\n3. Key Mapper version: %s\n4. Key maps (make a backup in the home screen menu)\n5. Screenshot of Key Mapper home screen\n6. Describe the problem you are having
The advanced triggers are paid feature but you downloaded the FOSS build of Key Mapper that does not include this closed source module or Google Play billing. Please download Key Mapper from Google Play to get access to this feature.
Key Mapper FOSS support
- Floating Buttons and Assistant trigger aren\'t sold in this FOSS build because RevenueCat and Google Play in-app billing aren\'t included.\n\nYou can still support development on Ko-fi.
+ Floating Buttons and Assistant trigger aren\'t available in this FOSS build.\n\nYou can still support development on Ko-fi.
If you donate and later get Google Play on another device,\
contact us
\ and we will give you the paid features so you do not need to pay again.
@@ -1694,6 +1765,7 @@
Sort
More
Help
+ What\'s New
Select all
Deselect all
Stop selecting
@@ -1757,6 +1829,7 @@
Group constraints
New constraint
Delete group constraint
+ Tap a constraint to invert it
This group
@@ -1794,6 +1867,17 @@
Remove
Edit
Test
+ Expand
+ Collapse
+ Rename
+ Duplicate
+ Add delay
+ Remove
+ No repeat
+ No burst
+ Burst %dx
+ No hold down
+ Hold down for %dms
Add actions to set what the key map should do once triggered.
Recently used actions
Action options
@@ -1808,6 +1892,32 @@
Cancel
Run these actions when you trigger the key map:
Repeating under 20 ms can lag! Adjust for your device and game.
+ Action name
+ %s options
+ Customize
+ Swap action
+ Repeat options
+ This action can be set to repeat multiple times for one trigger.
+ Don\'t repeat
+ Repeat a fixed number of times
+ Repeat continuously until the trigger is released
+ Not available for side key, assistant or fingerprint gesture triggers!
+ Repeat continuously until the trigger is pressed again
+ Before repeating, wait…
+ Then repeat this action every…
+ Maximum repeat limit
+ Burst options
+ The action can be performed multiple times in a row in a rapid-fire burst.
+ Every time the action repeats, it can be performed multiple times in a row in a rapid-fire burst.
+ Burst size
+ Hold down options
+ This action can be held down instead of being pressed and released immediately.
+ This action can be set to be held down for a period of time, regardless of how long you press the trigger.
+ Don\'t hold down
+ Hold down for a period of time
+ Hold down until the trigger is released
+ Hold down until the trigger is pressed again
+ Delay options
Choose side
Brightness
@@ -1834,9 +1944,32 @@
Are you sure you want to delete this constraint?
Yes, delete
Cancel
- Logic mode
+ This key map will only run if these constraints are met.
+ NOT
+ NOT %s
+ Invert constraint with NOT
+ Single constraint
+
+ - %d AND constraint
+ - %d AND constraints
+
+
+ - %d OR constraint
+ - %d OR constraints
+
+ This constraint must be met:
+ All of these constraints must be met at the same time:
+ At least one of these constraints must be met:
+ Expand constraint group
+ 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
Choose a constraint
- This key map will only run if:
Untitled group
@@ -2130,4 +2263,44 @@
Disable auto-switch keyboard?
Choosing a keyboard here will disable the automatic keyboard switching feature. You can re-enable it in Settings.
+
+ Copy
+ Copy %s
+
+
+ Information
+ Screen off detection
+ Feedback
+ Timing
+ Integration with other apps
+ Key map ID
+ Detect trigger when the screen is off
+ This key map will vibrate when triggered
+ Some text will display on the screen when this key map is triggered
+ Trigger by intent
+ Enable/disable by intent
+ Use key maps with Tasker, Automate, etc.
+ Create app shortcut
+ Use key maps with other apps
+ Your launcher doesn\'t support creating shortcuts so you must create it manually
+
+
+ Any app that can send an intent, such as Tasker or Automate, can trigger this key map. Send a broadcast to Key Mapper with these settings. The intent MUST name the receiver below because Android does not deliver implicit broadcasts to other apps.
+ Any app that can send an intent, such as Tasker or Automate, can turn this key map on and off without opening Key Mapper. Send a broadcast to Key Mapper with these settings.
+ Package
+ Class
+ Action
+ Turn on
+ Turn off
+ Toggle on/off
+ String extra name
+ String extra value (this key map)
+ Target a broadcast receiver with the following
+ Pick the one action you want to send
+ Specify this key map with the following extra
+ Test it with ADB
+ Command
+ Run this from a computer to check your setup before building it in another app.
+ Key Mapper intent
+
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 593aa20497..048114e820 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
@@ -16,6 +16,66 @@ import org.junit.Test
*/
class ActionDataEntityMapperTest {
+ @Test
+ fun `open app action with no app name extra is loaded with a null saved app name`() {
+ val entity = ActionEntity(
+ type = ActionEntity.Type.APP,
+ data = "com.example",
+ )
+
+ assertThat(
+ ActionDataEntityMapper.fromEntity(entity),
+ `is`(ActionData.App(packageName = "com.example", savedAppName = null)),
+ )
+ }
+
+ @Test
+ fun `open app action with a saved app name round trips through the entity`() {
+ val action = ActionData.App(packageName = "com.example", savedAppName = "Example")
+
+ val entity = ActionDataEntityMapper.toEntity(action)
+
+ assertThat(
+ entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(),
+ `is`("Example"),
+ )
+ assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action))
+ }
+
+ @Test
+ fun `app shortcut action with a saved app name round trips through the entity`() {
+ val action = ActionData.AppShortcut(
+ packageName = "com.example",
+ shortcutTitle = "Do the thing",
+ uri = "intent:...",
+ savedAppName = "Example",
+ )
+
+ val entity = ActionDataEntityMapper.toEntity(action)
+
+ assertThat(
+ entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(),
+ `is`("Example"),
+ )
+ assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action))
+ }
+
+ @Test
+ fun `control media for app action with a saved app name round trips through the entity`() {
+ val action = ActionData.ControlMediaForApp.Pause(
+ packageName = "com.example",
+ savedAppName = "Example",
+ )
+
+ val entity = ActionDataEntityMapper.toEntity(action)
+
+ assertThat(
+ entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(),
+ `is`("Example"),
+ )
+ assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action))
+ }
+
@Test
fun `save and load the screen resolution of a tap screen action`() {
// GIVEN
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt
new file mode 100644
index 0000000000..7ecd30102e
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt
@@ -0,0 +1,57 @@
+package io.github.sds100.keymapper.base.actions
+
+import io.github.sds100.keymapper.base.keymaps.KeyMap
+import io.github.sds100.keymapper.common.utils.hasFlag
+import io.github.sds100.keymapper.data.entities.ActionEntity
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+import org.hamcrest.Matchers.nullValue
+import org.junit.Test
+
+class ActionEntityMapperTest {
+
+ @Test
+ fun `save and restore the custom name of an action`() {
+ val action = Action(data = ActionData.ConsumeKeyEvent, customName = "My action")
+
+ val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single()
+
+ assertThat(ActionEntityMapper.fromEntity(entity)?.customName, `is`("My action"))
+ }
+
+ @Test
+ fun `do not save a blank custom name`() {
+ val action = Action(data = ActionData.ConsumeKeyEvent, customName = " ")
+
+ val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single()
+
+ assertThat(ActionEntityMapper.fromEntity(entity)?.customName, nullValue())
+ }
+
+ @Test
+ fun `action is enabled when the disabled flag is not set`() {
+ val entity =
+ ActionEntity(type = ActionEntity.Type.SYSTEM_ACTION, data = "consume_key_event")
+
+ assertThat(ActionEntityMapper.fromEntity(entity)?.isEnabled, `is`(true))
+ }
+
+ @Test
+ fun `save and restore a disabled action`() {
+ val action = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false)
+
+ val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single()
+
+ assertThat(entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), `is`(true))
+ assertThat(ActionEntityMapper.fromEntity(entity)?.isEnabled, `is`(false))
+ }
+
+ @Test
+ fun `do not set the disabled flag for an enabled action`() {
+ val action = Action(data = ActionData.ConsumeKeyEvent, isEnabled = true)
+
+ val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single()
+
+ assertThat(entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), `is`(false))
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt
new file mode 100644
index 0000000000..f23f099a80
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt
@@ -0,0 +1,62 @@
+package io.github.sds100.keymapper.base.actions
+
+import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
+import io.github.sds100.keymapper.common.utils.KMError
+import io.github.sds100.keymapper.common.utils.Success
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+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.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class ActionUiHelperTest {
+
+ private lateinit var mockUseCase: DisplayActionUseCase
+ private lateinit var helper: ActionUiHelper
+
+ @Before
+ fun init() {
+ mockUseCase = mock()
+
+ val mockResourceProvider: ResourceProvider = mock()
+ whenever(mockResourceProvider.getString(any(), any())).thenAnswer {
+ it.getArgument(1).toString()
+ }
+
+ helper = ActionUiHelper(mockUseCase, mockResourceProvider)
+ }
+
+ @Test
+ fun `open app title uses the live app name when the app is installed`() {
+ whenever(mockUseCase.getAppName("com.example")).thenReturn(Success("Live Name"))
+
+ val action = ActionData.App(packageName = "com.example", savedAppName = "Saved Name")
+
+ assertThat(helper.getTitle(action, showDeviceDescriptors = false), `is`("Live Name"))
+ }
+
+ @Test
+ fun `open app title falls back to the saved app name when the app is uninstalled`() {
+ whenever(mockUseCase.getAppName("com.example"))
+ .thenReturn(KMError.AppNotFound("com.example"))
+
+ val action = ActionData.App(packageName = "com.example", savedAppName = "Saved Name")
+
+ assertThat(helper.getTitle(action, showDeviceDescriptors = false), `is`("Saved Name"))
+ }
+
+ @Test
+ fun `open app title falls back to the raw package name when no name was ever saved`() {
+ whenever(mockUseCase.getAppName("com.example"))
+ .thenReturn(KMError.AppNotFound("com.example"))
+
+ val action = ActionData.App(packageName = "com.example", savedAppName = null)
+
+ assertThat(helper.getTitle(action, showDeviceDescriptors = false), `is`("com.example"))
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt
index 96309f4a02..d978ad5e6b 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt
@@ -6,6 +6,8 @@ import io.github.sds100.keymapper.base.constraints.ConstraintData
import io.github.sds100.keymapper.base.keymaps.ClickType
import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl
import io.github.sds100.keymapper.base.keymaps.KeyMap
+import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey
+import io.github.sds100.keymapper.base.trigger.AssistantTriggerType
import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice
import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey
import io.github.sds100.keymapper.base.utils.singleKeyTrigger
@@ -17,9 +19,12 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
+import org.hamcrest.Matchers.not
+import org.hamcrest.Matchers.nullValue
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
@@ -73,6 +78,102 @@ class ConfigActionsUseCaseTest {
assertThat(actionList[0].repeat, `is`(false))
}
+ @Test
+ fun `add key event action with assistant trigger, do not enable repeat`() =
+ runTest(testDispatcher) {
+ configKeyMapState.setKeyMap(
+ KeyMap(
+ trigger = singleKeyTrigger(
+ AssistantTriggerKey(
+ type = AssistantTriggerType.ANY,
+ clickType = ClickType.SHORT_PRESS,
+ ),
+ ),
+ ),
+ )
+
+ useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W))
+
+ val actionList = useCase.keyMap.value.dataOrNull()!!.actionList
+ assertThat(actionList[0].repeat, `is`(false))
+ }
+
+ @Test
+ fun `add key event action with key event trigger, repeat until released`() =
+ runTest(testDispatcher) {
+ configKeyMapState.setKeyMap(
+ KeyMap(
+ trigger = singleKeyTrigger(
+ KeyEventTriggerKey(
+ keyCode = KeyEvent.KEYCODE_VOLUME_DOWN,
+ device = KeyEventTriggerDevice.Internal,
+ clickType = ClickType.SHORT_PRESS,
+ ),
+ ),
+ ),
+ )
+
+ useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W))
+
+ val action = useCase.keyMap.value.dataOrNull()!!.actionList[0]
+ assertThat(action.repeat, `is`(true))
+ assertThat(action.repeatMode, `is`(RepeatMode.TRIGGER_RELEASED))
+ }
+
+ @Test
+ fun `set custom name, trim the name and remove it when blank`() = runTest(testDispatcher) {
+ configKeyMapState.setKeyMap(KeyMap())
+ useCase.addAction(ActionData.ConsumeKeyEvent)
+
+ val uid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid
+
+ useCase.setActionCustomName(uid, " My action ")
+ assertThat(
+ useCase.keyMap.value.dataOrNull()!!.actionList[0].customName,
+ `is`("My action"),
+ )
+
+ useCase.setActionCustomName(uid, " ")
+ assertThat(useCase.keyMap.value.dataOrNull()!!.actionList[0].customName, nullValue())
+ }
+
+ @Test
+ fun `disable an action, only that action is disabled`() = runTest(testDispatcher) {
+ configKeyMapState.setKeyMap(KeyMap())
+ useCase.addAction(ActionData.ConsumeKeyEvent)
+ useCase.addAction(ActionData.GoHome)
+
+ val uid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid
+
+ useCase.setActionEnabled(uid, false)
+
+ val actionList = useCase.keyMap.value.dataOrNull()!!.actionList
+ assertThat(actionList[0].isEnabled, `is`(false))
+ assertThat(actionList[1].isEnabled, `is`(true))
+
+ useCase.setActionEnabled(uid, true)
+ assertThat(useCase.keyMap.value.dataOrNull()!!.actionList[0].isEnabled, `is`(true))
+ }
+
+ @Test
+ fun `duplicate an action, insert a copy with a new uid directly after the original`() =
+ runTest(testDispatcher) {
+ configKeyMapState.setKeyMap(KeyMap())
+ useCase.addAction(ActionData.ConsumeKeyEvent)
+ useCase.addAction(ActionData.GoHome)
+
+ val originalUid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid
+
+ useCase.duplicateAction(originalUid)
+
+ val actionList = useCase.keyMap.value.dataOrNull()!!.actionList
+ assertThat(actionList.size, `is`(3))
+ assertThat(actionList[0].uid, `is`(originalUid))
+ assertThat(actionList[1].data, `is`(ActionData.ConsumeKeyEvent))
+ assertThat(actionList[1].uid, `is`(not(originalUid)))
+ assertThat(actionList[2].data, `is`(ActionData.GoHome))
+ }
+
/**
* Issue #852. Add a phone ringing constraint when you add an action
* to answer a phone call.
@@ -88,7 +189,9 @@ class ConfigActionsUseCaseTest {
useCase.addAction(action)
// THEN
- verify(mockConfigConstraintsUseCase).addConstraint(any())
+ verify(
+ mockConfigConstraintsUseCase,
+ ).addConstraint(anyOrNull(), any())
}
/**
@@ -106,7 +209,9 @@ class ConfigActionsUseCaseTest {
useCase.addAction(action)
// THEN
- verify(mockConfigConstraintsUseCase).addConstraint(any())
+ verify(
+ mockConfigConstraintsUseCase,
+ ).addConstraint(anyOrNull(), any())
}
/**
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 7e98e38ec4..fedf064c79 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
@@ -3,11 +3,14 @@ package io.github.sds100.keymapper.base.actions
import io.github.sds100.keymapper.base.input.InputEventHub
import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService
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.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.inputmethod.ImeInfo
import io.github.sds100.keymapper.system.popup.ToastAdapter
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -18,6 +21,8 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
@@ -38,6 +43,8 @@ class PerformActionsUseCaseTest {
private lateinit var mockToastAdapter: ToastAdapter
private lateinit var mockInputEventHub: InputEventHub
private lateinit var mockDisplayAdapter: DisplayAdapter
+ private lateinit var fakeInputMethodAdapter: FakeInputMethodAdapter
+ private lateinit var mockSwitchImeInterface: SwitchImeInterface
@Before
fun init() {
@@ -46,11 +53,13 @@ class PerformActionsUseCaseTest {
mockToastAdapter = mock()
mockInputEventHub = mock()
mockDisplayAdapter = mock()
+ fakeInputMethodAdapter = FakeInputMethodAdapter()
+ mockSwitchImeInterface = mock()
useCase = PerformActionsUseCaseImpl(
service = mockAccessibilityService,
- inputMethodAdapter = mock(),
- switchImeInterface = mock(),
+ inputMethodAdapter = fakeInputMethodAdapter,
+ switchImeInterface = mockSwitchImeInterface,
fileAdapter = mock(),
suAdapter = mock {},
shell = mock(),
@@ -75,7 +84,6 @@ class PerformActionsUseCaseTest {
resourceProvider = mock(),
settingsRepository = mock(),
soundsManager = mock(),
- notificationReceiverAdapter = mock(),
ringtoneAdapter = mock(),
inputEventHub = mockInputEventHub,
systemBridgeConnectionManager = mock(),
@@ -83,6 +91,7 @@ class PerformActionsUseCaseTest {
coroutineScope = testCoroutineScope,
notificationAdapter = mock(),
settingsAdapter = mock(),
+ vibratorAdapter = mock(),
)
}
@@ -273,4 +282,75 @@ class PerformActionsUseCaseTest {
// THEN
verify(mockAccessibilityService).tapScreen(eq(540), eq(1200), any())
}
+
+ /**
+ * issue #1501
+ */
+ @Test
+ fun `cycle keyboard action switches to the next input method`() = runTest(testDispatcher) {
+ // GIVEN
+ val imeA = ImeInfo("ime.a", "ime.a", "Keyboard A", isEnabled = true, isChosen = true)
+ val imeB = ImeInfo("ime.b", "ime.b", "Keyboard B", isEnabled = true, isChosen = false)
+
+ fakeInputMethodAdapter.inputMethods.value = listOf(imeA, imeB)
+ fakeInputMethodAdapter.chosenIme.value = imeA
+
+ // Simulate the input method actually switching once switchIme is called.
+ whenever(mockSwitchImeInterface.switchIme(any())).doAnswer { invocation ->
+ val imeId = invocation.getArgument(0)
+ fakeInputMethodAdapter.chosenIme.value =
+ fakeInputMethodAdapter.inputMethods.value.first { it.id == imeId }
+ Success(Unit)
+ }
+
+ // WHEN
+ useCase.perform(ActionData.CycleKeyboard)
+
+ // THEN
+ verify(mockSwitchImeInterface).switchIme(eq("ime.b"))
+ verify(mockToastAdapter, never()).show(any(), any())
+ }
+
+ /**
+ * issue #1501
+ */
+ @Test
+ fun `cycle keyboard action shows an error if the keyboard does not switch in time`() =
+ runTest(testDispatcher) {
+ // GIVEN
+ val imeA = ImeInfo("ime.a", "ime.a", "Keyboard A", isEnabled = true, isChosen = true)
+ val imeB = ImeInfo("ime.b", "ime.b", "Keyboard B", isEnabled = true, isChosen = false)
+
+ fakeInputMethodAdapter.inputMethods.value = listOf(imeA, imeB)
+ fakeInputMethodAdapter.chosenIme.value = imeA
+ // The chosen IME never actually changes to ime.b.
+
+ // WHEN
+ useCase.perform(ActionData.CycleKeyboard)
+
+ // THEN
+ verify(mockSwitchImeInterface).switchIme(eq("ime.b"))
+ verify(mockToastAdapter).show(anyOrNull(), any())
+ }
+
+ /**
+ * issue #1501
+ */
+ @Test
+ fun `cycle keyboard action does not switch when there is only one enabled keyboard`() =
+ runTest(testDispatcher) {
+ // GIVEN
+ val onlyIme = ImeInfo("ime.a", "ime.a", "Keyboard A", isEnabled = true, isChosen = true)
+
+ fakeInputMethodAdapter.inputMethods.value = listOf(onlyIme)
+ fakeInputMethodAdapter.chosenIme.value = onlyIme
+
+ // WHEN
+ useCase.perform(ActionData.CycleKeyboard)
+
+ // THEN it still "switches" to the same keyboard rather than showing an error, and
+ // since that keyboard is already chosen the confirmation succeeds immediately.
+ verify(mockSwitchImeInterface).switchIme(eq("ime.a"))
+ verify(mockToastAdapter, never()).show(any(), any())
+ }
}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt
new file mode 100644
index 0000000000..1a7e3e8ed5
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt
@@ -0,0 +1,29 @@
+package io.github.sds100.keymapper.base.actions
+
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+import org.junit.Test
+
+class VibrateActionEntityMapperTest {
+
+ @Test
+ fun `save and load a vibrate action with a custom duration`() {
+ val action = ActionData.Vibrate(VibrateEffect.CustomDuration(500L))
+
+ val entity = ActionDataEntityMapper.toEntity(action)
+
+ assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action))
+ }
+
+ @Test
+ fun `save and load a vibrate action with a predefined effect`() {
+ val action = ActionData.Vibrate(
+ VibrateEffect.Predefined(VibrateEffect.PredefinedType.DOUBLE_CLICK),
+ )
+
+ val entity = ActionDataEntityMapper.toEntity(action)
+
+ assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action))
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt
new file mode 100644
index 0000000000..9c7c6c943b
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt
@@ -0,0 +1,64 @@
+package io.github.sds100.keymapper.base.constraints
+
+import com.github.salomonbrys.kotson.fromJson
+import com.github.salomonbrys.kotson.registerTypeAdapter
+import com.google.gson.GsonBuilder
+import io.github.sds100.keymapper.data.entities.ActionEntity
+import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity
+import io.github.sds100.keymapper.data.entities.EntityExtra
+import io.github.sds100.keymapper.data.entities.KeyMapEntity
+import io.github.sds100.keymapper.data.entities.TriggerEntity
+import io.github.sds100.keymapper.data.entities.TriggerKeyEntity
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.empty
+import org.hamcrest.Matchers.`is`
+import org.hamcrest.Matchers.nullValue
+import org.junit.Test
+
+/**
+ * Key maps saved before constraint groups and the isNot/groupUid fields were added to
+ * ConstraintEntity have no "isNot", "groupUid" or "constraintGroups" keys in their JSON at all.
+ */
+class ConstraintEntityDeserializerTest {
+
+ private val gson = GsonBuilder()
+ .registerTypeAdapter(ConstraintEntity.DESERIALIZER)
+ .registerTypeAdapter(ConstraintGroupEntity.DESERIALIZER)
+ .registerTypeAdapter(TriggerEntity.DESERIALIZER)
+ .registerTypeAdapter(TriggerKeyEntity.SERIALIZER)
+ .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER)
+ .registerTypeAdapter(ActionEntity.DESERIALIZER)
+ .registerTypeAdapter(EntityExtra.DESERIALIZER)
+ .registerTypeAdapter(KeyMapEntity.DESERIALIZER)
+ .create()
+
+ @Test
+ fun `constraint entity json without isNot or groupUid deserializes to defaults`() {
+ val json = """{"type":"constraint_screen_on","extras":[],"uid":"uid1"}"""
+
+ val entity = gson.fromJson(json)
+
+ assertThat(entity.isNot, `is`(false))
+ assertThat(entity.groupUid, nullValue())
+ }
+
+ @Test
+ fun `key map entity json without constraintGroups deserializes to an empty list`() {
+ val json = """
+ {
+ "trigger":{"extras":[],"flags":0,"keys":[],"mode":2},
+ "actionList":[],
+ "constraintList":[],
+ "constraintMode":1,
+ "flags":0,
+ "isEnabled":true,
+ "uid":"uid1"
+ }
+ """.trimIndent()
+
+ val entity = gson.fromJson(json)
+
+ assertThat(entity.constraintGroups, empty())
+ }
+}
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
new file mode 100644
index 0000000000..b3fdbad8ac
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt
@@ -0,0 +1,357 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.common.utils.valueOrNull
+import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.EntityExtra
+import io.github.sds100.keymapper.data.entities.getData
+import io.github.sds100.keymapper.system.camera.CameraLens
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+import org.junit.Test
+
+/**
+ * The constraints that were the inverse of another constraint were replaced by the
+ * positive constraint with isNot. They are migrated when they are read.
+ */
+@Suppress("DEPRECATION")
+class ConstraintEntityMapperTest {
+
+ companion object {
+ private const val UID = "constraint_uid"
+ }
+
+ @Test
+ fun `app not in foreground is migrated to NOT app in foreground`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.APP_NOT_FOREGROUND,
+ EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"),
+ )
+
+ assertMigratedToNot(entity, ConstraintData.AppInForeground("com.example"))
+ }
+
+ @Test
+ fun `app not playing media is migrated to NOT app playing media`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.APP_NOT_PLAYING_MEDIA,
+ EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"),
+ )
+
+ assertMigratedToNot(entity, ConstraintData.AppPlayingMedia("com.example"))
+ }
+
+ @Test
+ fun `no media playing is migrated to NOT media playing`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.NO_MEDIA_PLAYING)
+
+ assertMigratedToNot(entity, ConstraintData.MediaPlaying)
+ }
+
+ @Test
+ fun `bluetooth device disconnected is migrated to NOT bluetooth device connected`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.BT_DEVICE_DISCONNECTED,
+ EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, "00:11:22:33:44:55"),
+ EntityExtra(ConstraintEntity.EXTRA_BT_NAME, "Headphones"),
+ )
+
+ assertMigratedToNot(
+ entity,
+ ConstraintData.BtDeviceConnected(
+ bluetoothAddress = "00:11:22:33:44:55",
+ deviceName = "Headphones",
+ ),
+ )
+ }
+
+ @Test
+ fun `screen off is migrated to NOT screen on`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.SCREEN_OFF)
+
+ assertMigratedToNot(entity, ConstraintData.ScreenOn)
+ }
+
+ @Test
+ fun `back flashlight off is migrated to NOT back flashlight on`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.FLASHLIGHT_OFF,
+ EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, "option_lens_back"),
+ )
+
+ assertMigratedToNot(entity, ConstraintData.FlashlightOn(CameraLens.BACK))
+ }
+
+ @Test
+ fun `front flashlight off is migrated to NOT front flashlight on`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.FLASHLIGHT_OFF,
+ EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, "option_lens_front"),
+ )
+
+ assertMigratedToNot(entity, ConstraintData.FlashlightOn(CameraLens.FRONT))
+ }
+
+ @Test
+ fun `wifi off is migrated to NOT wifi on`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.WIFI_OFF)
+
+ assertMigratedToNot(entity, ConstraintData.WifiOn)
+ }
+
+ @Test
+ fun `wifi disconnected from a network is migrated to NOT wifi connected to the network`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.WIFI_DISCONNECTED,
+ EntityExtra(ConstraintEntity.EXTRA_SSID, "home"),
+ )
+
+ assertMigratedToNot(entity, ConstraintData.WifiConnected(ssid = "home"))
+ }
+
+ @Test
+ fun `wifi disconnected from any network is migrated to NOT wifi connected to any network`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.WIFI_DISCONNECTED)
+
+ assertMigratedToNot(entity, ConstraintData.WifiConnected(ssid = null))
+ }
+
+ @Test
+ fun `input method not chosen is migrated to NOT input method chosen`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.IME_NOT_CHOSEN,
+ EntityExtra(ConstraintEntity.EXTRA_IME_ID, "ime_id"),
+ EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, "Gboard"),
+ )
+
+ assertMigratedToNot(entity, ConstraintData.ImeChosen(imeId = "ime_id", imeLabel = "Gboard"))
+ }
+
+ @Test
+ fun `keyboard not showing is migrated to NOT keyboard showing`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.KEYBOARD_NOT_SHOWING)
+
+ assertMigratedToNot(entity, ConstraintData.KeyboardShowing)
+ }
+
+ @Test
+ fun `device is unlocked is migrated to NOT device is locked`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.DEVICE_IS_UNLOCKED)
+
+ assertMigratedToNot(entity, ConstraintData.DeviceIsLocked)
+ }
+
+ @Test
+ fun `lock screen not showing is migrated to NOT lock screen showing`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.LOCK_SCREEN_NOT_SHOWING)
+
+ assertMigratedToNot(entity, ConstraintData.LockScreenShowing)
+ }
+
+ @Test
+ fun `discharging is migrated to NOT charging`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.DISCHARGING)
+
+ assertMigratedToNot(entity, ConstraintData.Charging)
+ }
+
+ @Test
+ fun `notification panel not showing is migrated to NOT notification panel showing`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING)
+
+ assertMigratedToNot(entity, ConstraintData.NotificationPanelShowing)
+ }
+
+ @Test
+ fun `hinge open is not migrated because it is not the inverse of hinge closed`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.HINGE_OPEN)
+
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(Constraint(uid = UID, data = ConstraintData.HingeOpen, isNot = false)),
+ )
+ }
+
+ @Test
+ fun `not in phone call is not migrated because it is not the inverse of in phone call`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.NOT_IN_PHONE_CALL)
+
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(Constraint(uid = UID, data = ConstraintData.NotInPhoneCall, isNot = false)),
+ )
+ }
+
+ @Test
+ fun `constraint with isNot false is not inverted`() {
+ val entity = ConstraintEntity(UID, ConstraintEntity.SCREEN_ON)
+
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(Constraint(uid = UID, data = ConstraintData.ScreenOn, isNot = false)),
+ )
+ }
+
+ @Test
+ fun `constraint with isNot true is inverted`() {
+ val entity = ConstraintEntity(
+ type = ConstraintEntity.SCREEN_ON,
+ extras = emptyList(),
+ uid = UID,
+ isNot = true,
+ )
+
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(Constraint(uid = UID, data = ConstraintData.ScreenOn, isNot = true)),
+ )
+ }
+
+ @Test
+ fun `deprecated constraint type with isNot true becomes the positive constraint`() {
+ val entity = ConstraintEntity(
+ type = ConstraintEntity.SCREEN_OFF,
+ extras = emptyList(),
+ uid = UID,
+ isNot = true,
+ )
+
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(Constraint(uid = UID, data = ConstraintData.ScreenOn, isNot = false)),
+ )
+ }
+
+ @Test
+ fun `save NOT constraint with the positive type and isNot true`() {
+ val constraint = Constraint(
+ uid = UID,
+ data = ConstraintData.AppInForeground("com.example"),
+ isNot = true,
+ )
+
+ val entity = ConstraintEntityMapper.toEntity(constraint)
+
+ assertThat(entity.type, `is`(ConstraintEntity.APP_FOREGROUND))
+ assertThat(entity.isNot, `is`(true))
+ assertThat(
+ entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull(),
+ `is`("com.example"),
+ )
+ }
+
+ @Test
+ fun `isNot is false if the constraint is not inverted`() {
+ val constraint = Constraint(uid = UID, data = ConstraintData.ScreenOn)
+
+ val entity = ConstraintEntityMapper.toEntity(constraint)
+
+ assertThat(entity.isNot, `is`(false))
+ }
+
+ @Test
+ fun `migrated constraints are saved with the positive type and loaded the same`() {
+ val deprecatedEntities = listOf(
+ ConstraintEntity(
+ UID,
+ ConstraintEntity.APP_NOT_FOREGROUND,
+ EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"),
+ ),
+ ConstraintEntity(
+ UID,
+ ConstraintEntity.APP_NOT_PLAYING_MEDIA,
+ EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"),
+ ),
+ ConstraintEntity(UID, ConstraintEntity.NO_MEDIA_PLAYING),
+ ConstraintEntity(
+ UID,
+ ConstraintEntity.BT_DEVICE_DISCONNECTED,
+ EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, "00:11:22:33:44:55"),
+ EntityExtra(ConstraintEntity.EXTRA_BT_NAME, "Headphones"),
+ ),
+ ConstraintEntity(UID, ConstraintEntity.SCREEN_OFF),
+ ConstraintEntity(
+ UID,
+ ConstraintEntity.FLASHLIGHT_OFF,
+ EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, "option_lens_back"),
+ ),
+ ConstraintEntity(UID, ConstraintEntity.WIFI_OFF),
+ ConstraintEntity(
+ UID,
+ ConstraintEntity.WIFI_DISCONNECTED,
+ EntityExtra(ConstraintEntity.EXTRA_SSID, "home"),
+ ),
+ ConstraintEntity(
+ UID,
+ ConstraintEntity.IME_NOT_CHOSEN,
+ EntityExtra(ConstraintEntity.EXTRA_IME_ID, "ime_id"),
+ EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, "Gboard"),
+ ),
+ ConstraintEntity(UID, ConstraintEntity.KEYBOARD_NOT_SHOWING),
+ ConstraintEntity(UID, ConstraintEntity.DEVICE_IS_UNLOCKED),
+ ConstraintEntity(UID, ConstraintEntity.LOCK_SCREEN_NOT_SHOWING),
+ ConstraintEntity(UID, ConstraintEntity.DISCHARGING),
+ ConstraintEntity(UID, ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING),
+ )
+
+ for (deprecatedEntity in deprecatedEntities) {
+ val migrated = ConstraintEntityMapper.fromEntity(deprecatedEntity)
+ val savedEntity = ConstraintEntityMapper.toEntity(migrated)
+
+ assertThat(
+ "${deprecatedEntity.type} must not be saved with a deprecated type",
+ savedEntity.type == deprecatedEntity.type,
+ `is`(false),
+ )
+ assertThat(ConstraintEntityMapper.fromEntity(savedEntity), `is`(migrated))
+ }
+ }
+
+ @Test
+ fun `app in foreground with no app name extra is loaded with a null app name`() {
+ val entity = ConstraintEntity(
+ UID,
+ ConstraintEntity.APP_FOREGROUND,
+ EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"),
+ )
+
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(
+ Constraint(
+ uid = UID,
+ data = ConstraintData.AppInForeground("com.example", appName = null),
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `app in foreground with a saved app name round trips through the entity`() {
+ val constraint = Constraint(
+ uid = UID,
+ data = ConstraintData.AppInForeground("com.example", appName = "Example"),
+ )
+
+ val entity = ConstraintEntityMapper.toEntity(constraint)
+
+ assertThat(
+ entity.extras.getData(ConstraintEntity.EXTRA_APP_NAME).valueOrNull(),
+ `is`("Example"),
+ )
+ assertThat(ConstraintEntityMapper.fromEntity(entity), `is`(constraint))
+ }
+
+ private fun assertMigratedToNot(entity: ConstraintEntity, expectedData: ConstraintData) {
+ assertThat(
+ ConstraintEntityMapper.fromEntity(entity),
+ `is`(Constraint(uid = UID, data = expectedData, isNot = true)),
+ )
+ }
+}
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
new file mode 100644
index 0000000000..6df3726b64
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt
@@ -0,0 +1,117 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.common.utils.KMError
+import io.github.sds100.keymapper.common.utils.Success
+import io.github.sds100.keymapper.system.apps.PackageManagerAdapter
+import io.github.sds100.keymapper.system.camera.CameraAdapter
+import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter
+import io.github.sds100.keymapper.system.permissions.PermissionAdapter
+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.junit.runner.RunWith
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class ConstraintErrorSnapshotTest {
+
+ private lateinit var mockPackageManagerAdapter: PackageManagerAdapter
+ private lateinit var mockPermissionAdapter: PermissionAdapter
+ private lateinit var mockInputMethodAdapter: InputMethodAdapter
+ private lateinit var snapshot: ConstraintErrorSnapshot
+
+ @Before
+ fun init() {
+ mockPackageManagerAdapter = mock()
+ mockPermissionAdapter = mock()
+ mockInputMethodAdapter = mock()
+ val mockSystemFeatureAdapter: SystemFeatureAdapter = mock()
+ val mockCameraAdapter: CameraAdapter = mock()
+
+ snapshot = LazyConstraintErrorSnapshot(
+ mockPackageManagerAdapter,
+ mockPermissionAdapter,
+ mockSystemFeatureAdapter,
+ mockInputMethodAdapter,
+ mockCameraAdapter,
+ )
+ }
+
+ private fun setAppMissing(packageName: String) {
+ whenever(mockPackageManagerAdapter.isAppEnabled(packageName)).thenReturn(Success(true))
+ whenever(mockPackageManagerAdapter.isAppInstalled(packageName)).thenReturn(false)
+ }
+
+ private fun setAppDisabled(packageName: String) {
+ // getAppError returns as soon as it finds the app is disabled, without checking
+ // whether it's installed, so isAppInstalled is never called for a disabled app.
+ whenever(mockPackageManagerAdapter.isAppEnabled(packageName)).thenReturn(Success(false))
+ }
+
+ @Test
+ fun `negated app in foreground constraint has no error when app is uninstalled`() {
+ setAppMissing("com.uninstalled")
+
+ val constraint = Constraint(
+ data = ConstraintData.AppInForeground("com.uninstalled"),
+ isNot = true,
+ )
+
+ assertThat(snapshot.getError(constraint), `is`(nullValue()))
+ }
+
+ @Test
+ fun `negated app in foreground constraint has no error when app is disabled`() {
+ setAppDisabled("com.disabled")
+
+ val constraint = Constraint(
+ data = ConstraintData.AppInForeground("com.disabled"),
+ isNot = true,
+ )
+
+ assertThat(snapshot.getError(constraint), `is`(nullValue()))
+ }
+
+ @Test
+ fun `non-negated app in foreground constraint still errors when app is uninstalled`() {
+ setAppMissing("com.uninstalled")
+
+ val constraint = Constraint(data = ConstraintData.AppInForeground("com.uninstalled"))
+
+ assertThat(
+ snapshot.getError(constraint),
+ `is`(KMError.AppNotFound("com.uninstalled") as KMError?),
+ )
+ }
+
+ @Test
+ fun `non-negated app in foreground constraint still errors when app is disabled`() {
+ setAppDisabled("com.disabled")
+
+ val constraint = Constraint(data = ConstraintData.AppInForeground("com.disabled"))
+
+ assertThat(
+ snapshot.getError(constraint),
+ `is`(KMError.AppDisabled("com.disabled") as KMError?),
+ )
+ }
+
+ @Test
+ fun `app not found error includes the saved app name when it is available`() {
+ setAppMissing("com.uninstalled")
+
+ val constraint = Constraint(
+ data = ConstraintData.AppInForeground("com.uninstalled", appName = "Example"),
+ )
+
+ assertThat(
+ snapshot.getError(constraint),
+ `is`(KMError.AppNotFound("com.uninstalled", "Example") as KMError?),
+ )
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt
index 116070ee6f..d07a26a5bc 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt
@@ -6,6 +6,31 @@ import org.hamcrest.Matchers.`is`
import org.junit.Test
class ConstraintSnapshotTest {
+
+ private val satisfied = Constraint(data = ConstraintData.AppInForeground("key_mapper"))
+ private val satisfied2 = Constraint(data = ConstraintData.ScreenOn)
+ private val unsatisfied = Constraint(data = ConstraintData.AppInForeground("google"))
+ private val unsatisfied2 = Constraint(data = ConstraintData.WifiOn)
+
+ /**
+ * The app in foreground and the screen is on. Wi-Fi is off.
+ */
+ private val snapshot = TestConstraintSnapshot(
+ appInForeground = "key_mapper",
+ isScreenOn = true,
+ isWifiEnabled = false,
+ )
+
+ private fun group(
+ vararg constraints: Constraint,
+ mode: ConstraintMode = ConstraintMode.AND,
+ ): ConstraintGroup = ConstraintGroup(constraints = constraints.toList(), mode = mode)
+
+ private fun state(
+ vararg groups: ConstraintGroup,
+ mode: ConstraintMode = ConstraintMode.AND,
+ ): ConstraintState = ConstraintState(groups = groups.toList(), mode = mode)
+
@Test
fun `When two constraints in three states, one OR and one AND, and all satisfied return true`() {
val snapshot = TestConstraintSnapshot(
@@ -15,34 +40,29 @@ class ConstraintSnapshotTest {
isLockscreenShowing = true,
)
- val state1 = ConstraintState(
- constraints =
- setOf(
+ val state1 = state(
+ group(
Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")),
- Constraint(data = ConstraintData.Discharging),
+ Constraint(data = ConstraintData.Charging, isNot = true),
+ mode = ConstraintMode.AND,
),
- mode = ConstraintMode.AND,
)
- val state2 =
- ConstraintState(
- constraints =
- setOf(
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.DeviceIsUnlocked),
- ),
+ val state2 = state(
+ group(
+ Constraint(data = ConstraintData.LockScreenShowing, isNot = true),
+ Constraint(data = ConstraintData.DeviceIsLocked, isNot = true),
mode = ConstraintMode.OR,
- )
-
- val state3 =
- ConstraintState(
- constraints =
- setOf(
- Constraint(data = ConstraintData.LockScreenShowing),
- Constraint(data = ConstraintData.DeviceIsUnlocked),
- ),
+ ),
+ )
+
+ val state3 = state(
+ group(
+ Constraint(data = ConstraintData.LockScreenShowing),
+ Constraint(data = ConstraintData.DeviceIsLocked, isNot = true),
mode = ConstraintMode.AND,
- )
+ ),
+ )
assertThat(snapshot.isSatisfied(state1, state2, state3), `is`(true))
}
@@ -55,24 +75,21 @@ class ConstraintSnapshotTest {
isLocked = true,
)
- val state1 = ConstraintState(
- constraints =
- setOf(
+ val state1 = state(
+ group(
Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")),
- Constraint(data = ConstraintData.Discharging),
+ Constraint(data = ConstraintData.Charging, isNot = true),
+ mode = ConstraintMode.AND,
),
- mode = ConstraintMode.AND,
)
- val state2 =
- ConstraintState(
- constraints =
- setOf(
- Constraint(data = ConstraintData.Charging),
- Constraint(data = ConstraintData.DeviceIsUnlocked),
- ),
+ val state2 = state(
+ group(
+ Constraint(data = ConstraintData.Charging),
+ Constraint(data = ConstraintData.DeviceIsLocked, isNot = true),
mode = ConstraintMode.OR,
- )
+ ),
+ )
assertThat(snapshot.isSatisfied(state1, state2), `is`(false))
}
@@ -85,157 +102,320 @@ class ConstraintSnapshotTest {
isLocked = true,
)
- val state1 = ConstraintState(
- constraints =
- setOf(
+ val state1 = state(
+ group(
Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")),
Constraint(data = ConstraintData.Charging),
+ mode = ConstraintMode.AND,
),
- mode = ConstraintMode.AND,
)
- val state2 =
- ConstraintState(
- constraints =
- setOf(
- Constraint(data = ConstraintData.Charging),
- Constraint(data = ConstraintData.DeviceIsUnlocked),
- ),
+ val state2 = state(
+ group(
+ Constraint(data = ConstraintData.Charging),
+ Constraint(data = ConstraintData.DeviceIsLocked, isNot = true),
mode = ConstraintMode.OR,
- )
+ ),
+ )
assertThat(snapshot.isSatisfied(state1, state2), `is`(true))
}
@Test
fun `When one constraint in two states and all satisfied return true`() {
- val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper", isCharging = true)
-
- val state1 = ConstraintState(
- constraints =
- setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper"))),
+ assertThat(
+ snapshot.isSatisfied(state(group(satisfied)), state(group(satisfied2))),
+ `is`(true),
)
+ }
- val state2 =
- ConstraintState(
- constraints =
- setOf(Constraint(data = ConstraintData.Charging)),
- )
+ @Test
+ fun `When one constraint in two states and all unsatisfied return false`() {
+ assertThat(
+ snapshot.isSatisfied(state(group(unsatisfied)), state(group(unsatisfied2))),
+ `is`(false),
+ )
+ }
- assertThat(snapshot.isSatisfied(state1, state2), `is`(true))
+ @Test
+ fun `When one constraint in two states and one unsatisfied return false`() {
+ assertThat(
+ snapshot.isSatisfied(state(group(unsatisfied)), state(group(satisfied))),
+ `is`(false),
+ )
}
@Test
- fun `When one constraint in two states and all unsatisfied return false`() {
- val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper")
+ fun `When no constraints in two states return true`() {
+ assertThat(snapshot.isSatisfied(state(), state()), `is`(true))
+ }
- val state1 = ConstraintState(
- constraints =
- setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google"))),
+ @Test
+ fun `When no constraints in two states with mixed constraint modes return true`() {
+ assertThat(
+ snapshot.isSatisfied(state(mode = ConstraintMode.OR), state(mode = ConstraintMode.AND)),
+ `is`(true),
)
+ }
- val state2 =
- ConstraintState(
- constraints =
- setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google1"))),
- )
+ @Test
+ fun `When one constraint and unsatisfied return false`() {
+ assertThat(snapshot.isSatisfied(state(group(unsatisfied))), `is`(false))
+ }
- assertThat(snapshot.isSatisfied(state1, state2), `is`(false))
+ @Test
+ fun `When one constraint and satisfied return true`() {
+ assertThat(snapshot.isSatisfied(state(group(satisfied))), `is`(true))
}
@Test
- fun `When one constraint in two states and one unsatisfied return false`() {
- val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper")
+ fun `When no constraints return true`() {
+ assertThat(snapshot.isSatisfied(state()), `is`(true))
+ }
- val state1 = ConstraintState(
- constraints =
- setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google"))),
+ @Test
+ fun `When keyboard is showing and constraint is KeyboardShowing return true`() {
+ val snapshot = TestConstraintSnapshot(isKeyboardShowing = true)
+ val constraint = Constraint(data = ConstraintData.KeyboardShowing)
+ assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(true))
+ }
+
+ @Test
+ fun `When keyboard is not showing and constraint is KeyboardShowing return false`() {
+ val snapshot = TestConstraintSnapshot(isKeyboardShowing = false)
+ val constraint = Constraint(data = ConstraintData.KeyboardShowing)
+ assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(false))
+ }
+
+ @Test
+ fun `When keyboard is not showing and constraint is NOT KeyboardShowing return true`() {
+ val snapshot = TestConstraintSnapshot(isKeyboardShowing = false)
+ val constraint = Constraint(data = ConstraintData.KeyboardShowing, isNot = true)
+ assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(true))
+ }
+
+ @Test
+ fun `When keyboard is showing and constraint is NOT KeyboardShowing return false`() {
+ val snapshot = TestConstraintSnapshot(isKeyboardShowing = true)
+ val constraint = Constraint(data = ConstraintData.KeyboardShowing, isNot = true)
+ assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(false))
+ }
+
+ @Test
+ fun `NOT on a satisfied constraint makes it unsatisfied`() {
+ assertThat(snapshot.isSatisfied(satisfied.copy(isNot = true)), `is`(false))
+ }
+
+ @Test
+ fun `NOT on an unsatisfied constraint makes it satisfied`() {
+ assertThat(snapshot.isSatisfied(unsatisfied.copy(isNot = true)), `is`(true))
+ }
+
+ @Test
+ fun `AND group with all satisfied constraints is satisfied`() {
+ val state = state(group(satisfied, satisfied2, mode = ConstraintMode.AND))
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
+
+ @Test
+ fun `AND group with one unsatisfied constraint is not satisfied`() {
+ val state = state(group(satisfied, unsatisfied, mode = ConstraintMode.AND))
+ assertThat(snapshot.isSatisfied(state), `is`(false))
+ }
+
+ @Test
+ fun `OR group with one satisfied constraint is satisfied`() {
+ val state = state(group(unsatisfied, satisfied, mode = ConstraintMode.OR))
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
+
+ @Test
+ fun `OR group with no satisfied constraints is not satisfied`() {
+ val state = state(group(unsatisfied, unsatisfied2, mode = ConstraintMode.OR))
+ assertThat(snapshot.isSatisfied(state), `is`(false))
+ }
+
+ @Test
+ fun `AND between groups when all groups are satisfied`() {
+ val state = state(
+ group(satisfied),
+ group(satisfied2),
+ mode = ConstraintMode.AND,
)
- val state2 =
- ConstraintState(
- constraints =
- setOf(
- Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")),
- ),
- )
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
- assertThat(snapshot.isSatisfied(state1, state2), `is`(false))
+ @Test
+ fun `AND between groups when one group is not satisfied`() {
+ val state = state(
+ group(satisfied),
+ group(unsatisfied),
+ mode = ConstraintMode.AND,
+ )
+
+ assertThat(snapshot.isSatisfied(state), `is`(false))
}
@Test
- fun `When no constraints in two states return true`() {
- val snapshot = TestConstraintSnapshot()
+ fun `OR between groups when one group is satisfied`() {
+ val state = state(
+ group(unsatisfied),
+ group(satisfied),
+ mode = ConstraintMode.OR,
+ )
+
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
- val state1 = ConstraintState(constraints = emptySet())
- val state2 = ConstraintState(constraints = emptySet())
+ @Test
+ fun `OR between groups when no groups are satisfied`() {
+ val state = state(
+ group(unsatisfied),
+ group(unsatisfied2),
+ mode = ConstraintMode.OR,
+ )
- assertThat(snapshot.isSatisfied(state1, state2), `is`(true))
+ assertThat(snapshot.isSatisfied(state), `is`(false))
}
@Test
- fun `When no constraints in two states with mixed constraint modes return true`() {
- val snapshot = TestConstraintSnapshot()
+ fun `(A OR B) AND (C OR D) when each group has one satisfied constraint`() {
+ val state = state(
+ group(unsatisfied, satisfied, mode = ConstraintMode.OR),
+ group(satisfied2, unsatisfied2, mode = ConstraintMode.OR),
+ mode = ConstraintMode.AND,
+ )
- val state1 = ConstraintState(constraints = emptySet(), mode = ConstraintMode.OR)
- val state2 = ConstraintState(constraints = emptySet(), mode = ConstraintMode.AND)
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
- assertThat(snapshot.isSatisfied(state1, state2), `is`(true))
+ @Test
+ fun `(A OR B) AND (C OR D) when one group has no satisfied constraints`() {
+ val state = state(
+ group(unsatisfied, satisfied, mode = ConstraintMode.OR),
+ group(unsatisfied, unsatisfied2, mode = ConstraintMode.OR),
+ mode = ConstraintMode.AND,
+ )
+
+ assertThat(snapshot.isSatisfied(state), `is`(false))
}
@Test
- fun `When one constraint and unsatisfied return false`() {
- val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper")
- val constraint = Constraint(data = ConstraintData.AppInForeground(packageName = "google"))
- val state = ConstraintState(constraints = setOf(constraint))
+ fun `(A AND B) OR (C AND D) when only the second group is fully satisfied`() {
+ val state = state(
+ group(satisfied, unsatisfied, mode = ConstraintMode.AND),
+ group(satisfied, satisfied2, mode = ConstraintMode.AND),
+ mode = ConstraintMode.OR,
+ )
+
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
+
+ @Test
+ fun `(A AND B) OR (C AND D) when neither group is fully satisfied`() {
+ val state = state(
+ group(satisfied, unsatisfied, mode = ConstraintMode.AND),
+ group(unsatisfied2, satisfied2, mode = ConstraintMode.AND),
+ mode = ConstraintMode.OR,
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(false))
}
@Test
- fun `When one constraint and satisfied return true`() {
- val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper")
- val constraint =
- Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper"))
- val state = ConstraintState(constraints = setOf(constraint))
+ fun `(A AND B) AND (C OR D) with mixed group modes`() {
+ val state = state(
+ group(satisfied, satisfied2, mode = ConstraintMode.AND),
+ group(unsatisfied, satisfied, mode = ConstraintMode.OR),
+ mode = ConstraintMode.AND,
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(true))
}
@Test
- fun `When no constraints return true`() {
- val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper")
- val state = ConstraintState(constraints = emptySet())
+ fun `(A OR B) OR (C AND D) with mixed group modes when only the OR group is satisfied`() {
+ val state = state(
+ group(unsatisfied, satisfied, mode = ConstraintMode.OR),
+ group(satisfied2, unsatisfied2, mode = ConstraintMode.AND),
+ mode = ConstraintMode.OR,
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(true))
}
@Test
- fun `When keyboard is showing and constraint is KeyboardShowing return true`() {
- val snapshot = TestConstraintSnapshot(isKeyboardShowing = true)
- val constraint = Constraint(data = ConstraintData.KeyboardShowing)
- val state = ConstraintState(constraints = setOf(constraint))
+ fun `NOT constraints inside groups are inverted before combining the groups`() {
+ // (NOT unsatisfied AND satisfied) AND (NOT satisfied OR NOT unsatisfied2)
+ val state = state(
+ group(unsatisfied.copy(isNot = true), satisfied, mode = ConstraintMode.AND),
+ group(
+ satisfied.copy(isNot = true),
+ unsatisfied2.copy(isNot = true),
+ mode = ConstraintMode.OR,
+ ),
+ mode = ConstraintMode.AND,
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(true))
}
@Test
- fun `When keyboard is not showing and constraint is KeyboardShowing return false`() {
- val snapshot = TestConstraintSnapshot(isKeyboardShowing = false)
- val constraint = Constraint(data = ConstraintData.KeyboardShowing)
- val state = ConstraintState(constraints = setOf(constraint))
+ fun `NOT on every constraint in an OR group is not satisfied when all are satisfied`() {
+ val state = state(
+ group(
+ satisfied.copy(isNot = true),
+ satisfied2.copy(isNot = true),
+ mode = ConstraintMode.OR,
+ ),
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(false))
}
@Test
- fun `When keyboard is not showing and constraint is KeyboardNotShowing return true`() {
- val snapshot = TestConstraintSnapshot(isKeyboardShowing = false)
- val constraint = Constraint(data = ConstraintData.KeyboardNotShowing)
- val state = ConstraintState(constraints = setOf(constraint))
+ fun `Empty groups are ignored when combining groups with AND`() {
+ val state = state(
+ group(),
+ group(satisfied),
+ mode = ConstraintMode.AND,
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(true))
}
@Test
- fun `When keyboard is showing and constraint is KeyboardNotShowing return false`() {
- val snapshot = TestConstraintSnapshot(isKeyboardShowing = true)
- val constraint = Constraint(data = ConstraintData.KeyboardNotShowing)
- val state = ConstraintState(constraints = setOf(constraint))
+ fun `Empty groups do not satisfy an OR between groups`() {
+ val state = state(
+ group(),
+ group(unsatisfied),
+ mode = ConstraintMode.OR,
+ )
+
assertThat(snapshot.isSatisfied(state), `is`(false))
}
+
+ @Test
+ fun `Only empty groups are satisfied`() {
+ val state = state(group(), group(mode = ConstraintMode.OR), mode = ConstraintMode.OR)
+
+ assertThat(snapshot.isSatisfied(state), `is`(true))
+ }
+
+ @Test
+ fun `Multiple states with groups are combined with AND`() {
+ val keyMapState = state(
+ group(unsatisfied),
+ group(satisfied),
+ mode = ConstraintMode.OR,
+ )
+
+ val satisfiedGroupState = state(group(satisfied, satisfied2))
+ val unsatisfiedGroupState =
+ state(group(unsatisfied, unsatisfied2, mode = ConstraintMode.OR))
+
+ assertThat(snapshot.isSatisfied(keyMapState, satisfiedGroupState), `is`(true))
+ assertThat(snapshot.isSatisfied(keyMapState, unsatisfiedGroupState), `is`(false))
+ }
}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt
new file mode 100644
index 0000000000..692a7dcebd
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt
@@ -0,0 +1,365 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.empty
+import org.hamcrest.Matchers.everyItem
+import org.hamcrest.Matchers.hasSize
+import org.hamcrest.Matchers.`is`
+import org.hamcrest.Matchers.nullValue
+import org.junit.Test
+
+class ConstraintStateEntityMapperTest {
+
+ @Test
+ fun `flat constraints are loaded as one group with the saved mode`() {
+ val entities = listOf(
+ ConstraintEntity("uid1", ConstraintEntity.SCREEN_ON),
+ ConstraintEntity("uid2", ConstraintEntity.CHARGING),
+ )
+
+ val state = ConstraintStateEntityMapper.fromEntity(entities, ConstraintEntity.MODE_OR)
+
+ assertThat(state.mode, `is`(ConstraintMode.OR))
+ assertThat(state.groups, hasSize(1))
+ assertThat(state.groups[0].mode, `is`(ConstraintMode.OR))
+ assertThat(
+ state.groups[0].constraints,
+ contains(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn),
+ Constraint(uid = "uid2", data = ConstraintData.Charging),
+ ),
+ )
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun `deprecated constraints are migrated in one group`() {
+ val entities = listOf(
+ ConstraintEntity("uid1", ConstraintEntity.SCREEN_OFF),
+ ConstraintEntity("uid2", ConstraintEntity.DISCHARGING),
+ )
+
+ val state = ConstraintStateEntityMapper.fromEntity(entities, ConstraintEntity.MODE_AND)
+
+ assertThat(state.groups.size, `is`(1))
+ assertThat(
+ state.allConstraints,
+ `is`(
+ listOf(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true),
+ Constraint(uid = "uid2", data = ConstraintData.Charging, isNot = true),
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `no constraints are loaded as no groups`() {
+ val state = ConstraintStateEntityMapper.fromEntity(emptyList(), ConstraintEntity.MODE_OR)
+
+ assertThat(state.groups, empty())
+ assertThat(state.mode, `is`(ConstraintMode.OR))
+ }
+
+ @Test
+ fun `save and load flat constraints keeps the constraints and mode`() {
+ val state = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ uid = "uid1",
+ constraints = listOf(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true),
+ Constraint(uid = "uid2", data = ConstraintData.Charging),
+ ),
+ mode = ConstraintMode.OR,
+ ),
+ ),
+ mode = ConstraintMode.OR,
+ )
+
+ val (entities, mode) = ConstraintStateEntityMapper.toEntity(state)
+
+ assertThat(entities.map { it.groupUid }, everyItem(nullValue()))
+
+ val loadedState = ConstraintStateEntityMapper.fromEntity(entities, mode)
+
+ assertThat(loadedState.mode, `is`(ConstraintMode.OR))
+ assertThat(loadedState.groups, hasSize(1))
+ assertThat(loadedState.groups[0].mode, `is`(ConstraintMode.OR))
+ assertThat(
+ state.groups[0].constraints,
+ contains(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true),
+ Constraint(uid = "uid2", data = ConstraintData.Charging),
+ ),
+ )
+ }
+
+ @Test
+ fun `no groups are saved with the constraint state mode`() {
+ val state = ConstraintState(groups = emptyList(), mode = ConstraintMode.OR)
+
+ val (entities, mode) = ConstraintStateEntityMapper.toEntity(state)
+
+ assertThat(entities, empty())
+ assertThat(mode, `is`(ConstraintEntity.MODE_OR))
+ }
+
+ @Test
+ fun `constraints saved before groups existed are loaded as one group with the saved mode`() {
+ val entities = listOf(
+ ConstraintEntity("uid1", ConstraintEntity.SCREEN_ON),
+ ConstraintEntity("uid2", ConstraintEntity.CHARGING),
+ )
+
+ val state = ConstraintStateEntityMapper.fromEntityWithGroups(
+ entities,
+ emptyList(),
+ ConstraintEntity.MODE_OR,
+ )
+
+ assertThat(state.mode, `is`(ConstraintMode.OR))
+ assertThat(state.groups, hasSize(1))
+ assertThat(state.groups[0].mode, `is`(ConstraintMode.OR))
+ assertThat(
+ state.groups[0].constraints,
+ contains(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn),
+ Constraint(uid = "uid2", data = ConstraintData.Charging),
+ ),
+ )
+ }
+
+ @Test
+ fun `no constraints with groups are loaded as no groups`() {
+ val state = ConstraintStateEntityMapper.fromEntityWithGroups(
+ emptyList(),
+ emptyList(),
+ ConstraintEntity.MODE_OR,
+ )
+
+ assertThat(state.groups, empty())
+ assertThat(state.mode, `is`(ConstraintMode.OR))
+ }
+
+ @Test
+ fun `no groups are saved with groups as the constraint state mode`() {
+ val state = ConstraintState(groups = emptyList(), mode = ConstraintMode.OR)
+
+ val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state)
+
+ assertThat(entities, empty())
+ assertThat(groups, empty())
+ assertThat(mode, `is`(ConstraintEntity.MODE_OR))
+ }
+
+ @Test
+ fun `one group is saved with a group entity and with the group mode`() {
+ val state = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ uid = "group1",
+ constraints = listOf(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn),
+ Constraint(uid = "uid2", data = ConstraintData.Charging, isNot = true),
+ ),
+ mode = ConstraintMode.OR,
+ ),
+ ),
+ mode = ConstraintMode.AND,
+ )
+
+ val (entities, groups, groupMode) = ConstraintStateEntityMapper.toEntityWithGroups(state)
+
+ assertThat(groupMode, `is`(ConstraintEntity.MODE_AND))
+ assertThat(groups, hasSize(1))
+ assertThat(
+ groups,
+ contains(ConstraintGroupEntity(uid = "group1", mode = ConstraintEntity.MODE_OR)),
+ )
+ assertThat(entities.map { it.groupUid }, everyItem(`is`("group1")))
+ }
+
+ @Test
+ fun `multiple groups are saved with a group entity per group and the mode between groups`() {
+ val state = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ uid = "group1",
+ name = "My group",
+ constraints = listOf(Constraint(uid = "uid1", data = ConstraintData.ScreenOn)),
+ mode = ConstraintMode.OR,
+ ),
+ ConstraintGroup(
+ uid = "group2",
+ constraints = listOf(Constraint(uid = "uid2", data = ConstraintData.Charging)),
+ mode = ConstraintMode.AND,
+ ),
+ ),
+ mode = ConstraintMode.OR,
+ )
+
+ val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state)
+
+ assertThat(mode, `is`(ConstraintEntity.MODE_OR))
+ assertThat(entities.map { it.groupUid }, `is`(listOf("group1", "group2")))
+ assertThat(
+ groups,
+ `is`(
+ listOf(
+ ConstraintGroupEntity(
+ uid = "group1",
+ name = "My group",
+ mode = ConstraintEntity.MODE_OR,
+ ),
+ ConstraintGroupEntity(
+ uid = "group2",
+ name = null,
+ mode = ConstraintEntity.MODE_AND,
+ ),
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `save and load multiple named groups keeps the constraints, names, modes and order`() {
+ val state = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ uid = "group1",
+ name = "Screen state",
+ constraints = listOf(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn),
+ Constraint(
+ uid = "uid2",
+ data = ConstraintData.AppInForeground("com.example"),
+ isNot = true,
+ ),
+ ),
+ mode = ConstraintMode.AND,
+ ),
+ ConstraintGroup(
+ uid = "group2",
+ constraints = listOf(
+ Constraint(uid = "uid3", data = ConstraintData.WifiOn, isNot = true),
+ ),
+ mode = ConstraintMode.OR,
+ ),
+ ConstraintGroup(
+ uid = "group3",
+ name = "Charging stuff",
+ constraints = listOf(
+ Constraint(uid = "uid4", data = ConstraintData.Charging),
+ Constraint(uid = "uid5", data = ConstraintData.KeyboardShowing),
+ ),
+ mode = ConstraintMode.OR,
+ ),
+ ),
+ mode = ConstraintMode.OR,
+ )
+
+ val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state)
+ val loadedState = ConstraintStateEntityMapper.fromEntityWithGroups(entities, groups, mode)
+
+ assertThat(loadedState, `is`(state))
+ }
+
+ @Test
+ fun `save and load one group keeps the constraints and mode`() {
+ val state = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ uid = "uid1",
+ constraints = listOf(
+ Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true),
+ Constraint(uid = "uid2", data = ConstraintData.Charging),
+ ),
+ mode = ConstraintMode.OR,
+ ),
+ ),
+ mode = ConstraintMode.OR,
+ )
+
+ val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state)
+ val loadedState = ConstraintStateEntityMapper.fromEntityWithGroups(entities, groups, mode)
+
+ assertThat(loadedState, `is`(state))
+ }
+
+ @Test
+ fun `constraints without a group uid are put in their own group when other constraints have groups`() {
+ val entities = listOf(
+ ConstraintEntity(
+ type = ConstraintEntity.SCREEN_ON,
+ extras = emptyList(),
+ uid = "uid1",
+ groupUid = "group1",
+ ),
+ ConstraintEntity("uid2", ConstraintEntity.CHARGING),
+ ConstraintEntity("uid3", ConstraintEntity.WIFI_ON),
+ )
+
+ val groupEntities = listOf(
+ ConstraintGroupEntity(uid = "group1", mode = ConstraintEntity.MODE_OR),
+ )
+
+ val state = ConstraintStateEntityMapper.fromEntityWithGroups(
+ entities,
+ groupEntities,
+ ConstraintEntity.MODE_AND,
+ )
+
+ val expected = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ uid = "group1",
+ constraints = listOf(Constraint(uid = "uid1", data = ConstraintData.ScreenOn)),
+ mode = ConstraintMode.OR,
+ ),
+ ConstraintGroup(
+ uid = "uid2",
+ constraints = listOf(
+ Constraint(uid = "uid2", data = ConstraintData.Charging),
+ Constraint(uid = "uid3", data = ConstraintData.WifiOn),
+ ),
+ mode = ConstraintMode.AND,
+ ),
+ ),
+ mode = ConstraintMode.AND,
+ )
+
+ assertThat(state, `is`(expected))
+ }
+
+ @Test
+ fun `group without a matching group entity uses the AND mode and no name`() {
+ val entities = listOf(
+ ConstraintEntity(
+ type = ConstraintEntity.SCREEN_ON,
+ extras = emptyList(),
+ uid = "uid1",
+ groupUid = "group1",
+ ),
+ ConstraintEntity(
+ type = ConstraintEntity.CHARGING,
+ extras = emptyList(),
+ uid = "uid2",
+ groupUid = "group2",
+ ),
+ )
+
+ val state = ConstraintStateEntityMapper.fromEntityWithGroups(
+ entities,
+ emptyList(),
+ ConstraintEntity.MODE_OR,
+ )
+
+ assertThat(state.groups.map { it.mode }, everyItem(`is`(ConstraintMode.AND)))
+ assertThat(state.groups.map { it.name }, everyItem(nullValue()))
+ assertThat(state.mode, `is`(ConstraintMode.OR))
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt
new file mode 100644
index 0000000000..f472f12880
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt
@@ -0,0 +1,68 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.base.utils.ui.ResourceProvider
+import io.github.sds100.keymapper.common.utils.KMError
+import io.github.sds100.keymapper.common.utils.Success
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+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.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class ConstraintUiHelperTest {
+
+ private lateinit var mockUseCase: DisplayConstraintUseCase
+ private lateinit var helper: ConstraintUiHelper
+
+ @Before
+ fun init() {
+ mockUseCase = mock()
+
+ val mockResourceProvider: ResourceProvider = mock()
+ whenever(mockResourceProvider.getString(any(), any())).thenAnswer {
+ it.getArgument(1).toString()
+ }
+
+ helper = ConstraintUiHelper(mockUseCase, mockResourceProvider)
+ }
+
+ @Test
+ fun `app in foreground title uses the live app name when the app is installed`() {
+ whenever(mockUseCase.getAppName("com.example")).thenReturn(Success("Live Name"))
+
+ val constraint = Constraint(
+ data = ConstraintData.AppInForeground("com.example", appName = "Saved Name"),
+ )
+
+ assertThat(helper.getTitle(constraint), `is`("Live Name"))
+ }
+
+ @Test
+ fun `app in foreground title falls back to the saved app name when the app is uninstalled`() {
+ whenever(mockUseCase.getAppName("com.example"))
+ .thenReturn(KMError.AppNotFound("com.example"))
+
+ val constraint = Constraint(
+ data = ConstraintData.AppInForeground("com.example", appName = "Saved Name"),
+ )
+
+ assertThat(helper.getTitle(constraint), `is`("Saved Name"))
+ }
+
+ @Test
+ fun `app in foreground title falls back to the raw package name when no name was ever saved`() {
+ whenever(mockUseCase.getAppName("com.example"))
+ .thenReturn(KMError.AppNotFound("com.example"))
+
+ val constraint = Constraint(
+ data = ConstraintData.AppInForeground("com.example", appName = null),
+ )
+
+ assertThat(helper.getTitle(constraint), `is`("com.example"))
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt
new file mode 100644
index 0000000000..faaf92e861
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt
@@ -0,0 +1,84 @@
+package io.github.sds100.keymapper.base.constraints
+
+import io.github.sds100.keymapper.system.notifications.PostedNotification
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+import org.junit.Test
+
+class NotificationConstraintMatcherTest {
+
+ private fun notification(
+ packageName: String = "com.termux",
+ title: String? = "keymap_mode=1",
+ text: String? = "Session running",
+ ) = PostedNotification(
+ key = "key",
+ packageName = packageName,
+ title = title,
+ text = text,
+ )
+
+ private fun matches(
+ data: ConstraintData.NotificationPosted,
+ notification: PostedNotification = notification(),
+ ) = NotificationConstraintMatcher.matches(notification, data)
+
+ private fun title(text: String, matchMode: TextMatchMode = TextMatchMode.CONTAINS) =
+ ConstraintData.NotificationPosted.Title(text, matchMode)
+
+ @Test
+ fun `contains matches a substring of the title and the text`() {
+ assertThat(matches(title("mode=1")), `is`(true))
+ assertThat(
+ matches(ConstraintData.NotificationPosted.Text("running", TextMatchMode.CONTAINS)),
+ `is`(true),
+ )
+ }
+
+ @Test
+ fun `contains does not match text that is not there`() {
+ assertThat(matches(title("mode=2")), `is`(false))
+ }
+
+ @Test
+ fun `exact requires the whole value`() {
+ assertThat(matches(title("keymap_mode=1", TextMatchMode.EXACT)), `is`(true))
+ assertThat(matches(title("keymap_mode", TextMatchMode.EXACT)), `is`(false))
+
+ // The case exact matching is there for: a prefix must not match a longer mode.
+ assertThat(
+ matches(
+ title("keymap_mode=1", TextMatchMode.EXACT),
+ notification = notification(title = "keymap_mode=10"),
+ ),
+ `is`(false),
+ )
+ }
+
+ @Test
+ fun `matching always ignores case`() {
+ assertThat(matches(title("KEYMAP")), `is`(true))
+ assertThat(matches(title("KEYMAP_MODE=1", TextMatchMode.EXACT)), `is`(true))
+ }
+
+ @Test
+ fun `the app is compared exactly`() {
+ assertThat(matches(ConstraintData.NotificationPosted.FromApp("com.termux")), `is`(true))
+ assertThat(matches(ConstraintData.NotificationPosted.FromApp("com.term")), `is`(false))
+ }
+
+ @Test
+ fun `a null field never matches`() {
+ assertThat(
+ matches(title("anything"), notification = notification(title = null)),
+ `is`(false),
+ )
+ assertThat(
+ matches(
+ ConstraintData.NotificationPosted.Text("anything", TextMatchMode.CONTAINS),
+ notification = notification(text = null),
+ ),
+ `is`(false),
+ )
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt
index a5f20562cf..a6b8856c7b 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt
@@ -1,7 +1,10 @@
package io.github.sds100.keymapper.base.keymaps
+import io.github.sds100.keymapper.base.actions.Action
+import io.github.sds100.keymapper.base.actions.ActionData
import io.github.sds100.keymapper.base.constraints.Constraint
import io.github.sds100.keymapper.base.constraints.ConstraintData
+import io.github.sds100.keymapper.base.constraints.ConstraintGroup
import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.constraints.ConstraintState
import io.github.sds100.keymapper.base.detection.DetectKeyMapModel
@@ -23,8 +26,8 @@ class DetectKeyMapsUseCaseTest {
"child",
parentUid = "parent",
mode = ConstraintMode.OR,
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.Discharging),
+ Constraint(data = ConstraintData.LockScreenShowing, isNot = true),
+ Constraint(data = ConstraintData.Charging, isNot = true),
),
group(
"parent",
@@ -44,8 +47,8 @@ class DetectKeyMapsUseCaseTest {
val keyMap = KeyMap(groupUid = "child")
val constraints1 = arrayOf(
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.Discharging),
+ Constraint(data = ConstraintData.LockScreenShowing, isNot = true),
+ Constraint(data = ConstraintData.Charging, isNot = true),
)
val constraints2 = arrayOf(
@@ -74,14 +77,8 @@ class DetectKeyMapsUseCaseTest {
val expected = DetectKeyMapModel(
keyMap,
groupConstraintStates = listOf(
- ConstraintState(
- constraints = constraints1.toSet(),
- mode = ConstraintMode.OR,
- ),
- ConstraintState(
- constraints = constraints2.toSet(),
- mode = ConstraintMode.AND,
- ),
+ constraintState(ConstraintMode.OR, *constraints1),
+ constraintState(ConstraintMode.AND, *constraints2),
),
)
assertThat(models, Matchers.contains(expected))
@@ -91,8 +88,8 @@ class DetectKeyMapsUseCaseTest {
fun `Key map in grandchild group and child only has constraints`() {
val keyMap = KeyMap(groupUid = "child")
val constraints1 = arrayOf(
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.Discharging),
+ Constraint(data = ConstraintData.LockScreenShowing, isNot = true),
+ Constraint(data = ConstraintData.Charging, isNot = true),
)
val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups(
keyMaps = listOf(keyMap),
@@ -113,10 +110,7 @@ class DetectKeyMapsUseCaseTest {
val expected = DetectKeyMapModel(
keyMap,
groupConstraintStates = listOf(
- ConstraintState(
- constraints = constraints1.toSet(),
- mode = ConstraintMode.OR,
- ),
+ constraintState(ConstraintMode.OR, *constraints1),
),
)
assertThat(models, Matchers.contains(expected))
@@ -126,8 +120,8 @@ class DetectKeyMapsUseCaseTest {
fun `Key map in grandchild group and parent only has constraints`() {
val keyMap = KeyMap(groupUid = "child")
val constraints1 = arrayOf(
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.Discharging),
+ Constraint(data = ConstraintData.LockScreenShowing, isNot = true),
+ Constraint(data = ConstraintData.Charging, isNot = true),
)
val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups(
@@ -146,10 +140,7 @@ class DetectKeyMapsUseCaseTest {
val expected = DetectKeyMapModel(
keyMap,
groupConstraintStates = listOf(
- ConstraintState(
- constraints = constraints1.toSet(),
- mode = ConstraintMode.OR,
- ),
+ constraintState(ConstraintMode.OR, *constraints1),
),
)
assertThat(models, Matchers.contains(expected))
@@ -211,6 +202,33 @@ class DetectKeyMapsUseCaseTest {
assertThat(models, Matchers.empty())
}
+ @Test
+ fun `Key map has disabled actions then remove the disabled actions`() {
+ val enabledAction = Action(data = ActionData.GoHome)
+ val disabledAction = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false)
+ val keyMap = KeyMap(actionList = listOf(enabledAction, disabledAction))
+
+ val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups(
+ keyMaps = listOf(keyMap),
+ groups = emptyList(),
+ )
+
+ assertThat(models.single().keyMap.actionList, Matchers.contains(enabledAction))
+ }
+
+ @Test
+ fun `Key map has only disabled actions then action list is empty`() {
+ val disabledAction = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false)
+ val keyMap = KeyMap(actionList = listOf(disabledAction))
+
+ val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups(
+ keyMaps = listOf(keyMap),
+ groups = emptyList(),
+ )
+
+ assertThat(models.single().keyMap.actionList, Matchers.empty())
+ }
+
@Test
fun `One key map in child group then include`() {
val keyMap = KeyMap(groupUid = "child")
@@ -286,12 +304,24 @@ class DetectKeyMapsUseCaseTest {
return Group(
uid = uid,
name = uid,
- constraintState = ConstraintState(
- constraints = constraint.toSet(),
- mode = mode,
- ),
+ constraintState = constraintState(mode, *constraint),
parentUid = parentUid,
lastOpenedDate = 0,
)
}
+
+ private fun constraintState(
+ mode: ConstraintMode,
+ vararg constraints: Constraint,
+ ): ConstraintState {
+ val groups = if (constraints.isEmpty()) {
+ emptyList()
+ } else {
+ listOf(
+ ConstraintGroup(uid = "group", constraints = constraints.toList(), mode = mode),
+ )
+ }
+
+ return ConstraintState(groups = groups, mode = mode)
+ }
}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt
index c0e5ef5ce6..ed71b52ad4 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt
@@ -11,6 +11,7 @@ import io.github.sds100.keymapper.base.actions.PerformActionsUseCase
import io.github.sds100.keymapper.base.actions.RepeatMode
import io.github.sds100.keymapper.base.constraints.Constraint
import io.github.sds100.keymapper.base.constraints.ConstraintData
+import io.github.sds100.keymapper.base.constraints.ConstraintGroup
import io.github.sds100.keymapper.base.constraints.ConstraintMode
import io.github.sds100.keymapper.base.constraints.ConstraintSnapshot
import io.github.sds100.keymapper.base.constraints.ConstraintState
@@ -31,6 +32,7 @@ import io.github.sds100.keymapper.base.utils.parallelTrigger
import io.github.sds100.keymapper.base.utils.sequenceTrigger
import io.github.sds100.keymapper.base.utils.singleKeyTrigger
import io.github.sds100.keymapper.base.utils.triggerKey
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.EvdevDeviceInfo
import io.github.sds100.keymapper.common.utils.InputDeviceInfo
import io.github.sds100.keymapper.common.utils.InputEventAction
@@ -768,27 +770,44 @@ class KeyMapAlgorithmTest {
trigger = trigger,
actionList = listOf(TEST_ACTION),
constraintState = ConstraintState(
- constraints = setOf(
- Constraint(data = ConstraintData.WifiOn),
- Constraint(data = ConstraintData.DeviceIsLocked),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(data = ConstraintData.WifiOn),
+ Constraint(data = ConstraintData.DeviceIsLocked),
+ ),
+ mode = ConstraintMode.OR,
+ ),
),
- mode = ConstraintMode.OR,
),
),
groupConstraintStates = listOf(
ConstraintState(
- constraints = setOf(
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.DeviceIsLocked),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(
+ data = ConstraintData.LockScreenShowing,
+ isNot = true,
+ ),
+ Constraint(data = ConstraintData.DeviceIsLocked),
+ ),
+ mode = ConstraintMode.AND,
+ ),
),
- mode = ConstraintMode.AND,
),
ConstraintState(
- constraints = setOf(
- Constraint(data = ConstraintData.AppInForeground(packageName = "app")),
- Constraint(data = ConstraintData.DeviceIsUnlocked),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(
+ data = ConstraintData.AppInForeground(packageName = "app"),
+ ),
+ Constraint(data = ConstraintData.DeviceIsLocked, isNot = true),
+ ),
+ mode = ConstraintMode.OR,
+ ),
),
- mode = ConstraintMode.OR,
),
),
),
@@ -819,29 +838,49 @@ class KeyMapAlgorithmTest {
trigger = trigger,
actionList = listOf(TEST_ACTION),
constraintState = ConstraintState(
- constraints = setOf(
- Constraint(data = ConstraintData.WifiOn),
- Constraint(data = ConstraintData.DeviceIsLocked),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(data = ConstraintData.WifiOn),
+ Constraint(data = ConstraintData.DeviceIsLocked),
+ ),
+ mode = ConstraintMode.OR,
+ ),
),
- mode = ConstraintMode.OR,
),
),
groupConstraintStates = listOf(
ConstraintState(
- constraints = setOf(
- Constraint(data = ConstraintData.LockScreenNotShowing),
- Constraint(data = ConstraintData.DeviceIsLocked),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(
+ data = ConstraintData.LockScreenShowing,
+ isNot = true,
+ ),
+ Constraint(data = ConstraintData.DeviceIsLocked),
+ ),
+ mode = ConstraintMode.AND,
+ ),
),
- mode = ConstraintMode.AND,
),
ConstraintState(
- constraints = setOf(
- Constraint(
- data = ConstraintData.AppInForeground(packageName = "app"),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(
+ data = ConstraintData.AppInForeground(
+ packageName = "app",
+ ),
+ ),
+ Constraint(
+ data = ConstraintData.DeviceIsLocked,
+ isNot = true,
+ ),
+ ),
+ mode = ConstraintMode.OR,
),
- Constraint(data = ConstraintData.DeviceIsUnlocked),
),
- mode = ConstraintMode.OR,
),
),
),
@@ -913,7 +952,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(1),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
/**
@@ -953,7 +995,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(1),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
/**
@@ -992,7 +1037,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(1),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
/**
@@ -1033,7 +1081,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(1),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
/**
@@ -1074,7 +1125,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(1),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
/**
@@ -1115,7 +1169,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(2)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(2),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
/**
@@ -1154,7 +1211,10 @@ class KeyMapAlgorithmTest {
advanceUntilIdle()
// THEN
- verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION)
+ verify(
+ detectKeyMapsUseCase,
+ times(1),
+ ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION))
}
@Test
@@ -1713,14 +1773,24 @@ class KeyMapAlgorithmTest {
val shortPressTrigger = singleKeyTrigger(
triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN),
)
- val shortPressConstraints =
- ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOn)))
+ val shortPressConstraints = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(constraints = listOf(Constraint(data = ConstraintData.WifiOn))),
+ ),
+ )
val longPressTrigger = singleKeyTrigger(
triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS),
)
- val doublePressConstraints =
- ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOff)))
+ val doublePressConstraints = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(data = ConstraintData.WifiOn, isNot = true),
+ ),
+ ),
+ ),
+ )
loadKeyMaps(
KeyMap(
@@ -1762,14 +1832,24 @@ class KeyMapAlgorithmTest {
val shortPressTrigger = singleKeyTrigger(
triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN),
)
- val shortPressConstraints =
- ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOn)))
+ val shortPressConstraints = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(constraints = listOf(Constraint(data = ConstraintData.WifiOn))),
+ ),
+ )
val doublePressTrigger = singleKeyTrigger(
triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS),
)
- val doublePressConstraints =
- ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOff)))
+ val doublePressConstraints = ConstraintState(
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(data = ConstraintData.WifiOn, isNot = true),
+ ),
+ ),
+ ),
+ )
loadKeyMaps(
KeyMap(
@@ -1913,8 +1993,14 @@ class KeyMapAlgorithmTest {
),
actionList = listOf(Action(data = actionData)),
constraintState = ConstraintState(
- constraints = setOf(
- Constraint(data = ConstraintData.FlashlightOn(lens = CameraLens.BACK)),
+ groups = listOf(
+ ConstraintGroup(
+ constraints = listOf(
+ Constraint(
+ data = ConstraintData.FlashlightOn(lens = CameraLens.BACK),
+ ),
+ ),
+ ),
),
),
)
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt
index 4bc6370c7e..8c94aad2a7 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt
@@ -6,9 +6,9 @@ import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot
import io.github.sds100.keymapper.base.actions.PerformActionsUseCase
import io.github.sds100.keymapper.base.actions.RepeatMode
import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase
+import io.github.sds100.keymapper.base.detection.DetectKeyMapModel
import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCase
import io.github.sds100.keymapper.base.detection.TriggerKeyMapFromOtherAppsController
-import io.github.sds100.keymapper.base.trigger.Trigger
import io.github.sds100.keymapper.base.utils.TestConstraintSnapshot
import io.github.sds100.keymapper.common.utils.KMError
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -46,14 +46,14 @@ class TriggerKeyMapFromOtherAppsControllerTest {
private lateinit var detectKeyMapsUseCase: DetectKeyMapsUseCase
private lateinit var performActionsUseCase: PerformActionsUseCase
private lateinit var detectConstraintsUseCase: DetectConstraintsUseCase
- private lateinit var keyMapListFlow: MutableStateFlow>
+ private lateinit var keyMapListFlow: MutableStateFlow>
@Before
fun init() {
keyMapListFlow = MutableStateFlow(emptyList())
detectKeyMapsUseCase = mock {
- on { keyMapsToTriggerFromOtherApps } doReturn keyMapListFlow
+ on { allKeyMapList } doReturn keyMapListFlow
MutableStateFlow(VIBRATION_DURATION).apply {
on { defaultVibrateDuration } doReturn this
@@ -108,9 +108,8 @@ class TriggerKeyMapFromOtherAppsControllerTest {
)
val keyMap = KeyMap(
actionList = listOf(action),
- trigger = Trigger(triggerFromOtherApps = true),
)
- keyMapListFlow.value = listOf(keyMap)
+ keyMapListFlow.value = listOf(DetectKeyMapModel(keyMap))
advanceUntilIdle()
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/onboarding/WhatsNewParserTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/onboarding/WhatsNewParserTest.kt
new file mode 100644
index 0000000000..08fe8b623f
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/onboarding/WhatsNewParserTest.kt
@@ -0,0 +1,93 @@
+package io.github.sds100.keymapper.base.onboarding
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class WhatsNewParserTest {
+
+ @Test
+ fun `parse each prefix into its section`() {
+ val notes = WhatsNewParser.parse(
+ """
+ ! Spotlight
+ + New feature
+ * Improvement
+ - Bug fix
+ """.trimIndent(),
+ )
+
+ assertEquals(
+ listOf(
+ WhatsNewItem("Spotlight", null, isSpotlight = true),
+ WhatsNewItem("New feature", null),
+ ),
+ notes.new,
+ )
+ assertEquals(listOf(WhatsNewItem("Improvement", null)), notes.improved)
+ assertEquals(listOf(WhatsNewItem("Bug fix", null)), notes.fixed)
+ }
+
+ @Test
+ fun `parse description after pipe`() {
+ val notes = WhatsNewParser.parse("! Title | Some description")
+
+ assertEquals(
+ listOf(WhatsNewItem("Title", "Some description", isSpotlight = true)),
+ notes.new,
+ )
+ }
+
+ @Test
+ fun `empty description is null`() {
+ val notes = WhatsNewParser.parse("+ Title | ")
+
+ assertEquals(listOf(WhatsNewItem("Title", null)), notes.new)
+ }
+
+ @Test
+ fun `skip blank lines and lines without a title`() {
+ val notes = WhatsNewParser.parse("\n \n+ Feature\n+\n\n")
+
+ assertEquals(listOf(WhatsNewItem("Feature", null)), notes.new)
+ }
+
+ @Test
+ fun `unknown prefix is treated as an improvement`() {
+ val notes = WhatsNewParser.parse("• Old style bullet\nNo prefix")
+
+ assertEquals(
+ listOf(WhatsNewItem("Old style bullet", null), WhatsNewItem("No prefix", null)),
+ notes.improved,
+ )
+ }
+
+ @Test
+ fun `each spotlight line becomes a spotlight item in new`() {
+ val notes = WhatsNewParser.parse("! First\n! Second")
+
+ assertEquals(
+ listOf(
+ WhatsNewItem("First", null, isSpotlight = true),
+ WhatsNewItem("Second", null, isSpotlight = true),
+ ),
+ notes.new,
+ )
+ }
+
+ @Test
+ fun `parse the real whats-new-app asset file`() {
+ val context = ApplicationProvider.getApplicationContext()
+ val text = context.assets.open("whats-new-app.txt")
+ .bufferedReader()
+ .use { it.readText() }
+
+ // The release notes change every release, so just confirm the real asset file parses
+ // without throwing rather than pinning exact content that would need updating each time.
+ WhatsNewParser.parse(text)
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt
index 4fc6301f4f..cebb370334 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt
@@ -40,4 +40,18 @@ class FakeInputMethodAdapter : InputMethodAdapter {
override fun cycleInputMethodSubtype(): KMResult {
return Success(Unit)
}
+
+ override fun getNextInputMethod(): KMResult {
+ val chosenImeId = chosenIme.value?.id
+ val enabledImes = inputMethods.value.filter { it.isEnabled }
+
+ if (enabledImes.isEmpty()) {
+ return getInfoById(chosenImeId.orEmpty())
+ }
+
+ val currentIndex = enabledImes.indexOfFirst { it.id == chosenImeId }
+ val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledImes.size
+
+ return Success(enabledImes[nextIndex])
+ }
}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt
new file mode 100644
index 0000000000..f25a8c8adc
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt
@@ -0,0 +1,77 @@
+package io.github.sds100.keymapper.base.system.notifications
+
+import io.github.sds100.keymapper.system.notifications.PostedNotification
+import io.github.sds100.keymapper.system.notifications.PostedNotificationStore
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.contains
+import org.hamcrest.Matchers.empty
+import org.hamcrest.Matchers.`is`
+import org.junit.Test
+
+class PostedNotificationStoreTest {
+
+ private val store = PostedNotificationStore()
+
+ private fun notification(key: String = "key", title: String? = "keymap_mode=1") =
+ PostedNotification(
+ key = key,
+ packageName = "com.termux",
+ title = title,
+ text = "Session running",
+ )
+
+ @Test
+ fun `posting adds to the active list`() {
+ store.onPosted(notification())
+
+ assertThat(store.active.value.size, `is`(1))
+ }
+
+ @Test
+ fun `the active list is in the order the notifications were posted`() {
+ // Dismissing the most recent notification relies on this order.
+ store.onPosted(notification(key = "first", title = "first"))
+ store.onPosted(notification(key = "second", title = "second"))
+ store.onPosted(notification(key = "third", title = "third"))
+
+ assertThat(store.active.value.map { it.title }, contains("first", "second", "third"))
+ assertThat(store.active.value.last().key, `is`("third"))
+ }
+
+ @Test
+ fun `updating a notification in place moves it to the end`() {
+ store.onPosted(notification(key = "first", title = "first"))
+ store.onPosted(notification(key = "second", title = "second"))
+ store.onPosted(notification(key = "first", title = "first updated"))
+
+ assertThat(store.active.value.map { it.title }, contains("second", "first updated"))
+ }
+
+ @Test
+ fun `removing a notification takes it out of the active list`() {
+ store.onPosted(notification(key = "first"))
+ store.onPosted(notification(key = "second"))
+
+ store.onRemoved("second")
+
+ assertThat(store.active.value.single().key, `is`("first"))
+ }
+
+ @Test
+ fun `seeding on reconnect replaces whatever was there`() {
+ store.onPosted(notification(key = "stale"))
+
+ store.seed(listOf(notification(key = "fresh")))
+
+ assertThat(store.active.value.single().key, `is`("fresh"))
+ }
+
+ @Test
+ fun `clearing removes everything so no content is left in memory`() {
+ store.onPosted(notification())
+
+ store.clear()
+
+ assertThat(store.active.value, `is`(empty()))
+ }
+}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt
index 312edb60aa..92857f4702 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt
@@ -7,6 +7,7 @@ import io.github.sds100.keymapper.base.utils.parallelTrigger
import io.github.sds100.keymapper.base.utils.sequenceTrigger
import io.github.sds100.keymapper.base.utils.singleKeyTrigger
import io.github.sds100.keymapper.base.utils.triggerKey
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
import io.github.sds100.keymapper.common.models.EvdevDeviceInfo
import io.github.sds100.keymapper.system.inputevents.Scancode
import org.hamcrest.MatcherAssert.assertThat
@@ -14,6 +15,7 @@ import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.`is`
+import org.hamcrest.Matchers.nullValue
import org.junit.After
import org.junit.Before
import org.junit.Test
@@ -1387,4 +1389,46 @@ class ConfigTriggerDelegateTest {
assertThat(newTrigger.keys, hasSize(1))
assertThat(newTrigger.keys, contains(trigger.keys[0]))
}
+
+ @Test
+ fun `set vibrate effect to a custom duration`() {
+ val trigger = Trigger(vibrate = true)
+
+ val newTrigger = delegate.setVibrateEffect(
+ trigger,
+ VibrateEffect.CustomDuration(250L),
+ defaultVibrateDuration = 100,
+ )
+
+ assertThat(newTrigger.vibrateEffect, `is`(VibrateEffect.CustomDuration(250L)))
+ }
+
+ @Test
+ fun `set vibrate effect to a predefined effect`() {
+ val trigger = Trigger(vibrate = true)
+
+ val newTrigger = delegate.setVibrateEffect(
+ trigger,
+ VibrateEffect.Predefined(VibrateEffect.PredefinedType.HEAVY_CLICK),
+ defaultVibrateDuration = 100,
+ )
+
+ assertThat(
+ newTrigger.vibrateEffect,
+ `is`(VibrateEffect.Predefined(VibrateEffect.PredefinedType.HEAVY_CLICK)),
+ )
+ }
+
+ @Test
+ fun `set vibrate effect to null when custom duration matches the app-wide default`() {
+ val trigger = Trigger(vibrate = true, vibrateEffect = VibrateEffect.CustomDuration(250L))
+
+ val newTrigger = delegate.setVibrateEffect(
+ trigger,
+ VibrateEffect.CustomDuration(100L),
+ defaultVibrateDuration = 100,
+ )
+
+ assertThat(newTrigger.vibrateEffect, `is`(nullValue()))
+ }
}
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt
new file mode 100644
index 0000000000..b73d8b8d3a
--- /dev/null
+++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt
@@ -0,0 +1,66 @@
+package io.github.sds100.keymapper.base.trigger
+
+import io.github.sds100.keymapper.base.vibration.VibrateEffect
+import io.github.sds100.keymapper.data.entities.EntityExtra
+import io.github.sds100.keymapper.data.entities.TriggerEntity
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.`is`
+import org.hamcrest.Matchers.nullValue
+import org.junit.Test
+
+class TriggerEntityMapperTest {
+
+ @Test
+ fun `save and load a custom vibration duration`() {
+ val trigger = Trigger(
+ vibrate = true,
+ vibrateEffect = VibrateEffect.CustomDuration(250L),
+ )
+
+ val entity = TriggerEntityMapper.toEntity(trigger)
+ val loadedTrigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList())
+
+ assertThat(loadedTrigger.vibrateEffect, `is`(VibrateEffect.CustomDuration(250L)))
+ }
+
+ @Test
+ fun `save and load a predefined vibration effect`() {
+ val trigger = Trigger(
+ vibrate = true,
+ vibrateEffect = VibrateEffect.Predefined(VibrateEffect.PredefinedType.TICK),
+ )
+
+ val entity = TriggerEntityMapper.toEntity(trigger)
+ val loadedTrigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList())
+
+ assertThat(
+ loadedTrigger.vibrateEffect,
+ `is`(VibrateEffect.Predefined(VibrateEffect.PredefinedType.TICK)),
+ )
+ }
+
+ @Test
+ fun `load a custom duration from a trigger saved before predefined effects existed`() {
+ // GIVEN an entity saved by an older version of the app that only stored a raw duration.
+ val entity = TriggerEntity(
+ flags = TriggerEntity.TRIGGER_FLAG_VIBRATE,
+ extras = listOf(
+ @Suppress("DEPRECATION")
+ EntityExtra(TriggerEntity.EXTRA_VIBRATION_DURATION, "250"),
+ ),
+ )
+
+ val trigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList())
+
+ assertThat(trigger.vibrateEffect, `is`(VibrateEffect.CustomDuration(250L)))
+ }
+
+ @Test
+ fun `load null vibrate effect when nothing was ever set`() {
+ val entity = TriggerEntity(flags = TriggerEntity.TRIGGER_FLAG_VIBRATE)
+
+ val trigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList())
+
+ assertThat(trigger.vibrateEffect, `is`(nullValue()))
+ }
+}
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 1af4cf04f2..7a01439985 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
@@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.utils
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.common.utils.Orientation
import io.github.sds100.keymapper.common.utils.PhysicalOrientation
import io.github.sds100.keymapper.common.utils.SizeKM
@@ -11,6 +12,7 @@ import io.github.sds100.keymapper.system.camera.CameraLens
import io.github.sds100.keymapper.system.foldable.HingeState
import io.github.sds100.keymapper.system.foldable.isClosed
import io.github.sds100.keymapper.system.foldable.isOpen
+import io.github.sds100.keymapper.system.notifications.PostedNotification
import io.github.sds100.keymapper.system.phone.CallState
import io.github.sds100.keymapper.system.volume.RingerMode
import java.time.LocalTime
@@ -38,29 +40,34 @@ class TestConstraintSnapshot(
val hingeState: HingeState = HingeState.Unavailable,
val isNotificationPanelShowing: Boolean = false,
val displaySize: SizeKM = SizeKM(1080, 1920),
+ val postedNotifications: List = emptyList(),
) : ConstraintSnapshot {
override fun isSatisfied(constraint: Constraint): Boolean {
val isSatisfied = when (val data = constraint.data) {
is ConstraintData.AppInForeground -> appInForeground == data.packageName
- is ConstraintData.AppNotInForeground -> appInForeground != data.packageName
+
is ConstraintData.AppPlayingMedia ->
appsPlayingMedia.contains(data.packageName)
- is ConstraintData.AppNotPlayingMedia ->
- appsPlayingMedia.none { it == data.packageName }
-
is ConstraintData.MediaPlaying -> appsPlayingMedia.isNotEmpty()
- is ConstraintData.NoMediaPlaying -> appsPlayingMedia.isEmpty()
- is ConstraintData.BtDeviceConnected -> {
- connectedBluetoothDevices.any { it.address == data.bluetoothAddress }
+
+ is ConstraintData.NotificationPosted -> if (constraint.isNot) {
+ postedNotifications.all {
+ NotificationConstraintMatcher.matches(it, constraint.data)
+ }
+ } else {
+ postedNotifications.any {
+ NotificationConstraintMatcher.matches(it, constraint.data)
+ }
}
- is ConstraintData.BtDeviceDisconnected -> {
- connectedBluetoothDevices.none { it.address == data.bluetoothAddress }
+ is ConstraintData.BtDeviceConnected -> {
+ connectedBluetoothDevices.any { it.address == data.bluetoothAddress }
}
is ConstraintData.OrientationCustom -> orientation == data.orientation
+
is ConstraintData.OrientationLandscape ->
orientation == Orientation.ORIENTATION_90 ||
orientation == Orientation.ORIENTATION_270
@@ -72,15 +79,11 @@ class TestConstraintSnapshot(
is ConstraintData.PhysicalOrientation ->
physicalOrientation == data.physicalOrientation
- is ConstraintData.ScreenOff -> !isScreenOn
is ConstraintData.ScreenOn -> isScreenOn
+
is ConstraintData.DisplayResolution ->
(displaySize.width == data.width && displaySize.height == data.height) ||
(displaySize.width == data.height && displaySize.height == data.width)
- is ConstraintData.FlashlightOff -> when (data.lens) {
- CameraLens.BACK -> !isBackFlashlightOn
- CameraLens.FRONT -> !isFrontFlashlightOn
- }
is ConstraintData.FlashlightOn -> when (data.lens) {
CameraLens.BACK -> isBackFlashlightOn
@@ -96,30 +99,26 @@ class TestConstraintSnapshot(
}
}
- is ConstraintData.WifiDisconnected ->
- if (data.ssid == null) {
- // connected to no network
- connectedWifiSSID == null
- } else {
- connectedWifiSSID != data.ssid
- }
-
- is ConstraintData.WifiOff -> !isWifiEnabled
is ConstraintData.WifiOn -> isWifiEnabled
+
is ConstraintData.ImeChosen -> chosenImeId == data.imeId
- is ConstraintData.ImeNotChosen -> chosenImeId != data.imeId
+
is ConstraintData.KeyboardShowing -> isKeyboardShowing
- is ConstraintData.KeyboardNotShowing -> !isKeyboardShowing
+
is ConstraintData.DeviceIsLocked -> isLocked
- is ConstraintData.DeviceIsUnlocked -> !isLocked
+
is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL
+
is ConstraintData.NotInPhoneCall -> callState == CallState.NONE
+
is ConstraintData.PhoneRinging -> callState == CallState.RINGING
+
is ConstraintData.RingerMode -> ringerMode == data.ringerMode
+
is ConstraintData.Charging -> isCharging
- is ConstraintData.Discharging -> !isCharging
+
is ConstraintData.LockScreenShowing -> isLockscreenShowing
- is ConstraintData.LockScreenNotShowing -> !isLockscreenShowing
+
is ConstraintData.Time -> {
val startTime = data.startTime
val endTime = data.endTime
@@ -133,11 +132,12 @@ class TestConstraintSnapshot(
ConstraintData.HingeClosed ->
hingeState is HingeState.Available && hingeState.isClosed()
+
ConstraintData.HingeOpen ->
hingeState is HingeState.Available && hingeState.isOpen()
+
ConstraintData.NotificationPanelShowing -> isNotificationPanelShowing
- ConstraintData.NotificationPanelNotShowing -> !isNotificationPanelShowing
- }
+ } != constraint.isNot
if (isSatisfied) {
Timber.d("Constraint satisfied: $constraint")
diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt
index fbf7c7b132..38fafe5c32 100644
--- a/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt
+++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt
@@ -17,6 +17,10 @@ class FakeResourceProvider : ResourceProvider {
return stringResourceMap[resId] ?: ""
}
+ override fun getPluralString(resId: Int, quantity: Int, vararg args: Any): String {
+ throw Exception()
+ }
+
override fun getText(resId: Int): CharSequence {
return stringResourceMap[resId] ?: ""
}
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 c5d3be8401..f0759b76b6 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
@@ -31,8 +31,8 @@ abstract class KMError : KMResult() {
data object BackupVersionTooNew : KMError()
data object LauncherShortcutsNotSupported : KMError()
- data class AppNotFound(val packageName: String) : KMError()
- data class AppDisabled(val packageName: String) : KMError()
+ data class AppNotFound(val packageName: String, val appName: String? = null) : KMError()
+ data class AppDisabled(val packageName: String, val appName: String? = null) : KMError()
data object AppShortcutCantBeOpened : KMError()
data object InsufficientPermissionsToOpenAppShortcut : KMError()
data object NoCompatibleImeEnabled : KMError()
diff --git a/crowdin.yaml b/crowdin.yaml
index 3551026c2e..e969cde2d5 100644
--- a/crowdin.yaml
+++ b/crowdin.yaml
@@ -6,7 +6,7 @@ preserve_hierarchy: true
files:
- source: /base/src/main/res/values/strings.xml
- translation: /base/src/main/res/values-%two_letters_code%/strings.xml
+ translation: /base/src/main/res/values-%android_code%/strings.xml
- source: /fastlane/metadata/android/en-US/full_description.txt
translation: /fastlane/metadata/android/%locale_with_underscore%/full_description.txt
- source: /fastlane/metadata/android/en-US/short_description.txt
diff --git a/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json
new file mode 100644
index 0000000000..b20d0c74de
--- /dev/null
+++ b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json
@@ -0,0 +1,482 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 23,
+ "identityHash": "37044a47f92b3ba990fd4a6e02386a09",
+ "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, 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"
+ }
+ ],
+ "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, '37044a47f92b3ba990fd4a6e02386a09')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt
index f1f9ed4fc1..0a5055381f 100644
--- a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt
+++ b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt
@@ -168,4 +168,6 @@ object Keys {
val handledMigrateScreenOffKeyMapsNotification =
booleanPreferencesKey("key_handled_migrate_screen_off_key_maps_notification")
+
+ val dismissedGamingModePromo = booleanPreferencesKey("pref_dismissed_gaming_mode_promo")
}
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 3cc6174bc2..522cc55bd9 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
@@ -35,6 +35,7 @@ import io.github.sds100.keymapper.data.migration.AutoMigration18To19
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.Migration10To11
import io.github.sds100.keymapper.data.migration.Migration11To12
import io.github.sds100.keymapper.data.migration.Migration13To14
@@ -70,6 +71,8 @@ import io.github.sds100.keymapper.data.migration.Migration9To10
AutoMigration(from = 20, to = 21, spec = AutoMigration20To21::class),
// Adds floating button option to lock in position
AutoMigration(from = 21, to = 22, spec = AutoMigration21To22::class),
+ // Adds the constraint_groups column to key maps
+ AutoMigration(from = 22, to = 23, spec = AutoMigration22To23::class),
],
)
@TypeConverters(
@@ -82,7 +85,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 = 22
+ const val DATABASE_VERSION = 23
val MIGRATION_1_2 = object : Migration(1, 2) {
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 ec692c2871..51eabc0605 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
@@ -20,6 +20,7 @@ interface KeyMapDao {
const val KEY_ACTION_LIST = "action_list"
const val KEY_CONSTRAINT_LIST = "constraint_list"
const val KEY_CONSTRAINT_MODE = "constraint_mode"
+ const val KEY_CONSTRAINT_GROUPS = "constraint_groups"
const val KEY_UID = "uid"
const val KEY_GROUP_UID = "group_uid"
}
diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt
index da676515d9..bac0754032 100644
--- a/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt
+++ b/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt
@@ -5,13 +5,24 @@ import com.github.salomonbrys.kotson.fromJson
import com.github.salomonbrys.kotson.registerTypeAdapter
import com.google.gson.GsonBuilder
import io.github.sds100.keymapper.data.entities.ConstraintEntity
+import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity
class ConstraintListTypeConverter {
- private val gson = GsonBuilder().registerTypeAdapter(ConstraintEntity.DESERIALIZER).create()
+ private val gson = GsonBuilder()
+ .registerTypeAdapter(ConstraintEntity.DESERIALIZER)
+ .registerTypeAdapter(ConstraintGroupEntity.DESERIALIZER)
+ .create()
@TypeConverter
fun toConstraintList(json: String) = gson.fromJson>(json)
@TypeConverter
fun toJsonString(constraintList: List) = gson.toJson(constraintList)!!
+
+ @TypeConverter
+ fun toConstraintGroupList(json: String) = gson.fromJson>(json)
+
+ @TypeConverter
+ fun constraintGroupListToJsonString(constraintGroups: List) =
+ gson.toJson(constraintGroups)!!
}
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 7d20d6dca6..adecee8ccc 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
@@ -69,6 +69,7 @@ data class ActionEntity(
const val EXTRA_SHORTCUT_TITLE = "extra_title"
const val EXTRA_PACKAGE_NAME = "extra_package_name"
+ const val EXTRA_APP_NAME = "extra_app_name"
const val EXTRA_STREAM_TYPE = "extra_stream_type"
const val EXTRA_LENS = "extra_flash"
const val EXTRA_RINGER_MODE = "extra_ringer_mode"
@@ -99,6 +100,11 @@ 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_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"
// Accessibility node extras
const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name"
@@ -142,6 +148,12 @@ data class ActionEntity(
const val ACTION_FLAG_SHELL_COMMAND_USE_ROOT = 16
const val ACTION_FLAG_SHELL_COMMAND_USE_ADB = 32
+ /**
+ * Stored as disabled rather than enabled so actions saved before this flag existed
+ * are enabled by default.
+ */
+ const val ACTION_FLAG_DISABLED = 64
+
const val EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR = "extra_custom_stop_repeat_behaviour"
const val EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR = "extra_custom_hold_down_behaviour"
const val EXTRA_REPEAT_DELAY = "extra_hold_down_until_repeat_delay"
@@ -150,6 +162,7 @@ data class ActionEntity(
const val EXTRA_DELAY_BEFORE_NEXT_ACTION = "extra_delay_before_next_action"
const val EXTRA_HOLD_DOWN_DURATION = "extra_hold_down_duration"
const val EXTRA_REPEAT_LIMIT = "extra_repeat_limit"
+ const val EXTRA_CUSTOM_NAME = "extra_custom_name"
const val EXTRA_SETTING_VALUE = "extra_setting_value"
const val EXTRA_SETTING_TYPE = "extra_setting_type"
@@ -210,6 +223,7 @@ data class ActionEntity(
MODIFY_SETTING,
CREATE_NOTIFICATION,
TOAST,
+ VIBRATE,
}
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 9e8a884ebe..90c5d0628b 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
@@ -2,6 +2,7 @@ package io.github.sds100.keymapper.data.entities
import android.os.Parcelable
import com.github.salomonbrys.kotson.byArray
+import com.github.salomonbrys.kotson.byBool
import com.github.salomonbrys.kotson.byNullableString
import com.github.salomonbrys.kotson.byString
import com.github.salomonbrys.kotson.jsonDeserializer
@@ -19,6 +20,12 @@ data class ConstraintEntity(
@SerializedName(NAME_UID)
val uid: String,
+
+ @SerializedName(NAME_IS_NOT)
+ val isNot: Boolean = false,
+
+ @SerializedName(NAME_GROUP_UID)
+ val groupUid: String? = null,
) : Parcelable {
constructor(uid: String, type: String, vararg extra: EntityExtra) : this(
@@ -32,6 +39,8 @@ data class ConstraintEntity(
const val NAME_TYPE = "type"
const val NAME_EXTRAS = "extras"
const val NAME_UID = "uid"
+ const val NAME_IS_NOT = "isNot"
+ const val NAME_GROUP_UID = "groupUid"
const val MODE_OR = 0
const val MODE_AND = 1
@@ -39,16 +48,26 @@ data class ConstraintEntity(
// types
const val APP_FOREGROUND = "constraint_app_foreground"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val APP_NOT_FOREGROUND = "constraint_app_not_foreground"
const val APP_PLAYING_MEDIA = "constraint_app_playing_media"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val APP_NOT_PLAYING_MEDIA = "constraint_app_not_playing_media"
const val MEDIA_PLAYING = "constraint_media_playing"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val NO_MEDIA_PLAYING = "constraint_no_media_playing"
const val BT_DEVICE_CONNECTED = "constraint_bt_device_connected"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val BT_DEVICE_DISCONNECTED = "constraint_bt_device_disconnected"
const val SCREEN_ON = "constraint_screen_on"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val SCREEN_OFF = "constraint_screen_off"
const val ORIENTATION_0 = "constraint_orientation_0"
@@ -68,22 +87,36 @@ data class ConstraintEntity(
const val DISPLAY_RESOLUTION = "constraint_display_resolution"
const val FLASHLIGHT_ON = "flashlight_on"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val FLASHLIGHT_OFF = "flashlight_off"
const val WIFI_ON = "wifi_on"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val WIFI_OFF = "wifi_off"
const val WIFI_CONNECTED = "wifi_connected"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val WIFI_DISCONNECTED = "wifi_disconnected"
const val IME_CHOSEN = "ime_chosen"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val IME_NOT_CHOSEN = "ime_not_chosen"
const val KEYBOARD_SHOWING = "keyboard_showing"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val KEYBOARD_NOT_SHOWING = "keyboard_not_showing"
const val DEVICE_IS_LOCKED = "is_locked"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val DEVICE_IS_UNLOCKED = "is_unlocked"
const val LOCK_SCREEN_SHOWING = "lock_screen_showing"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val LOCK_SCREEN_NOT_SHOWING = "lock_screen_not_showing"
const val IN_PHONE_CALL = "in_phone_call"
@@ -95,17 +128,27 @@ data class ConstraintEntity(
const val RINGER_MODE_SILENT = "ringer_mode_silent"
const val CHARGING = "charging"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val DISCHARGING = "discharging"
const val HINGE_CLOSED = "hinge_closed"
const val HINGE_OPEN = "hinge_open"
const val NOTIFICATION_PANEL_SHOWING = "notification_panel_showing"
+
+ @Deprecated(NOT_DEPRECATION_MESSAGE)
const val NOTIFICATION_PANEL_NOT_SHOWING = "notification_panel_not_showing"
+ private const val NOT_DEPRECATION_MESSAGE =
+ "Only read to migrate old constraints. Use the positive constraint type with isNot."
+
+ const val NOTIFICATION_POSTED = "constraint_notification_posted"
+
const val TIME = "time"
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"
const val EXTRA_BT_NAME = "extra_bluetooth_device_name"
const val EXTRA_FLASHLIGHT_CAMERA_LENS = "extra_flashlight_camera_lens"
@@ -113,6 +156,10 @@ data class ConstraintEntity(
const val EXTRA_IME_ID = "extra_ime_id"
const val EXTRA_IME_LABEL = "extra_ime_label"
+ const val EXTRA_NOTIFICATION_FIELD = "extra_notification_field"
+ const val EXTRA_NOTIFICATION_MATCH_MODE = "extra_notification_match_mode"
+ const val EXTRA_NOTIFICATION_VALUE = "extra_notification_value"
+
const val EXTRA_RESOLUTION_WIDTH = "extra_resolution_width"
const val EXTRA_RESOLUTION_HEIGHT = "extra_resolution_height"
@@ -131,10 +178,15 @@ data class ConstraintEntity(
// Constraints did not always have UID so this could be null.
val uid by it.json.byNullableString(NAME_UID)
+ val isNot by it.json.byBool(NAME_IS_NOT) { false }
+ val groupUid by it.json.byNullableString(NAME_GROUP_UID)
+
ConstraintEntity(
uid = uid ?: UUID.randomUUID().toString(),
type = type,
extras = extraList,
+ isNot = isNot,
+ groupUid = groupUid,
)
}
}
diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt
new file mode 100644
index 0000000000..e99d7e7cdb
--- /dev/null
+++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt
@@ -0,0 +1,36 @@
+package io.github.sds100.keymapper.data.entities
+
+import android.os.Parcelable
+import com.github.salomonbrys.kotson.byInt
+import com.github.salomonbrys.kotson.byNullableString
+import com.github.salomonbrys.kotson.byString
+import com.github.salomonbrys.kotson.jsonDeserializer
+import com.google.gson.annotations.SerializedName
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class ConstraintGroupEntity(
+ @SerializedName(NAME_UID)
+ val uid: String,
+
+ @SerializedName(NAME_NAME)
+ val name: String? = null,
+
+ @SerializedName(NAME_MODE)
+ val mode: Int = ConstraintEntity.DEFAULT_MODE,
+) : Parcelable {
+ companion object {
+ // DON'T CHANGE THESE. Used for JSON serialization and parsing.
+ const val NAME_UID = "uid"
+ const val NAME_NAME = "name"
+ const val NAME_MODE = "mode"
+
+ val DESERIALIZER = jsonDeserializer {
+ val uid by it.json.byString(NAME_UID)
+ val name by it.json.byNullableString(NAME_NAME)
+ val mode by it.json.byInt(NAME_MODE) { ConstraintEntity.DEFAULT_MODE }
+
+ ConstraintGroupEntity(uid = uid, name = name, mode = mode)
+ }
+ }
+}
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 a91eac14f2..c516336c78 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
@@ -9,6 +9,7 @@ import androidx.room.PrimaryKey
import com.github.salomonbrys.kotson.byArray
import com.github.salomonbrys.kotson.byBool
import com.github.salomonbrys.kotson.byInt
+import com.github.salomonbrys.kotson.byNullableArray
import com.github.salomonbrys.kotson.byNullableString
import com.github.salomonbrys.kotson.byObject
import com.github.salomonbrys.kotson.byString
@@ -56,6 +57,10 @@ data class KeyMapEntity(
@ColumnInfo(name = KeyMapDao.KEY_CONSTRAINT_MODE)
val constraintMode: Int = ConstraintEntity.DEFAULT_MODE,
+ @SerializedName(NAME_CONSTRAINT_GROUPS)
+ @ColumnInfo(name = KeyMapDao.KEY_CONSTRAINT_GROUPS, defaultValue = "[]")
+ val constraintGroups: List = listOf(),
+
/**
* Flags are stored as bits.
*/
@@ -83,6 +88,7 @@ data class KeyMapEntity(
const val NAME_ACTION_LIST = "actionList"
const val NAME_CONSTRAINT_LIST = "constraintList"
const val NAME_CONSTRAINT_MODE = "constraintMode"
+ const val NAME_CONSTRAINT_GROUPS = "constraintGroups"
const val NAME_FLAGS = "flags"
const val NAME_IS_ENABLED = "isEnabled"
const val NAME_UID = "uid"
@@ -99,6 +105,11 @@ data class KeyMapEntity(
val constraintList =
it.context.deserialize>(constraintListJsonArray)
+ val constraintGroupsJsonArray by it.json.byNullableArray(NAME_CONSTRAINT_GROUPS)
+ val constraintGroups = constraintGroupsJsonArray?.let { jsonArray ->
+ it.context.deserialize>(jsonArray)
+ } ?: listOf()
+
val constraintMode by it.json.byInt(NAME_CONSTRAINT_MODE)
val flags by it.json.byInt(NAME_FLAGS)
val isEnabled by it.json.byBool(NAME_IS_ENABLED)
@@ -111,6 +122,7 @@ data class KeyMapEntity(
actionList = actionList,
constraintList = constraintList,
constraintMode = constraintMode,
+ constraintGroups = constraintGroups,
flags = flags,
isEnabled = isEnabled,
uid = uid,
diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt
index ec2e77603d..9bdd6e7498 100644
--- a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt
+++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt
@@ -43,6 +43,9 @@ data class TriggerEntity(
@Deprecated("This is now on by default for evdev trigger keys")
const val TRIGGER_FLAG_SCREEN_OFF_TRIGGERS = 4
+ @Deprecated(
+ "All key maps can now be triggered by intent or shortcut. Do not reuse this bit.",
+ )
const val TRIGGER_FLAG_FROM_OTHER_APPS = 8
const val TRIGGER_FLAG_SHOW_TOAST = 16
@@ -51,7 +54,12 @@ data class TriggerEntity(
const val EXTRA_SEQUENCE_TRIGGER_TIMEOUT = "extra_sequence_trigger_timeout"
const val EXTRA_LONG_PRESS_DELAY = "extra_long_press_delay"
const val EXTRA_DOUBLE_PRESS_DELAY = "extra_double_press_timeout"
+
const val EXTRA_VIBRATION_DURATION = "extra_vibration_duration"
+ const val EXTRA_VIBRATE_MODE = "extra_vibrate_mode"
+ const val EXTRA_VIBRATE_EFFECT_TYPE = "extra_vibrate_effect_type"
+ const val VIBRATE_MODE_DURATION = "duration"
+ const val VIBRATE_MODE_PREDEFINED = "predefined"
val DESERIALIZER = jsonDeserializer {
val triggerKeysJsonArray by it.json.byArray(NAME_KEYS)
diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt
new file mode 100644
index 0000000000..37572864a6
--- /dev/null
+++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt
@@ -0,0 +1,5 @@
+package io.github.sds100.keymapper.data.migration
+
+import androidx.room.migration.AutoMigrationSpec
+
+class AutoMigration22To23 : AutoMigrationSpec
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f10514a86a..cd18a7178b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -31,7 +31,7 @@ androidx-viewpager2 = "1.1.0"
dagger-hilt-android = "2.56.2"
hilt-navigation-compose = "1.3.0"
-compose-bom = "2026.01.01"
+compose-bom = "2026.09.00"
compose-compiler = "1.5.10" # kotlinCompilerExtensionVersion
desugar-jdk-libs = "2.1.5"
@@ -61,8 +61,7 @@ material = "1.13.0"
mflisar-dragselectrecyclerview = "0.3"
mockito-android = "4.6.1"
-mockito-core = "5.15.2"
-mockito-inline = "5.2.0"
+mockito-core = "5.23.0"
mockito-kotlin = "4.0.0"
okhttp = "4.12.0"
@@ -170,7 +169,6 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
junit-params = { group = "pl.pragmatists", name = "JUnitParams", version.ref = "junit-params" }
mockito-android = { group = "org.mockito", name = "mockito-android", version.ref = "mockito-android" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito-core" }
-mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref = "mockito-inline" }
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
diff --git a/system/build.gradle.kts b/system/build.gradle.kts
index dbac603be3..efad88dd58 100644
--- a/system/build.gradle.kts
+++ b/system/build.gradle.kts
@@ -46,6 +46,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.core.ktx)
+ // Only for DynamicColors, to decide whether Key Mapper's notifications need tinting.
+ implementation(libs.google.android.material)
implementation(libs.jakewharton.timber)
implementation(libs.dagger.hilt.android)
ksp(libs.dagger.hilt.android.compiler)
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt
index 8909d67dcc..10b61433fe 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt
@@ -38,6 +38,8 @@ import io.github.sds100.keymapper.system.network.AndroidNetworkAdapter
import io.github.sds100.keymapper.system.network.NetworkAdapter
import io.github.sds100.keymapper.system.nfc.AndroidNfcAdapter
import io.github.sds100.keymapper.system.nfc.NfcAdapter
+import io.github.sds100.keymapper.system.notifications.AndroidNotificationAdapter
+import io.github.sds100.keymapper.system.notifications.NotificationAdapter
import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter
import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl
import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter
@@ -190,6 +192,10 @@ abstract class SystemHiltModule {
impl: NotificationReceiverAdapterImpl,
): NotificationReceiverAdapter
+ @Singleton
+ @Binds
+ abstract fun provideNotificationAdapter(impl: AndroidNotificationAdapter): NotificationAdapter
+
@Singleton
@Binds
abstract fun provideSystemFeatureAdapter(
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 3d4fc1e60c..02a6ed7229 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
@@ -449,4 +449,12 @@ class AndroidPackageManagerAdapter @Inject constructor(
override fun getInstallSourcePackageName(): String? {
return packageManager.getInstallSourceInfo(ctx.packageName).installingPackageName
}
+
+ override fun getInstallTime(packageName: String): Long {
+ return packageManager.getPackageInfo(packageName, 0).firstInstallTime
+ }
+
+ override fun getLastUpdateTime(packageName: String): Long {
+ return packageManager.getPackageInfo(packageName, 0).lastUpdateTime
+ }
}
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 191f1fb174..7dde97c963 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
@@ -20,6 +20,8 @@ interface PackageManagerAdapter {
fun getActivityIcon(packageName: String, activityClass: String): KMResult
fun isAppEnabled(packageName: String): KMResult
fun isAppInstalled(packageName: String): Boolean
+ fun getInstallTime(packageName: String): Long
+ fun getLastUpdateTime(packageName: String): Long
fun openApp(packageName: String): KMResult<*>
fun enableApp(packageName: String)
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt
index 59d04d2a8d..f6974cf554 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt
@@ -215,4 +215,18 @@ class AndroidInputMethodAdapter @Inject constructor(
KMError.CycleImeSubtypeFailed
}
}
+
+ override fun getNextInputMethod(): KMResult {
+ val chosenImeId = getChosenImeId()
+ val enabledImes = getInputMethods().filter { it.isEnabled }
+
+ if (enabledImes.isEmpty()) {
+ return getInfoById(chosenImeId)
+ }
+
+ val currentIndex = enabledImes.indexOfFirst { it.id == chosenImeId }
+ val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledImes.size
+
+ return Success(enabledImes[nextIndex])
+ }
}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt
index a072a7d949..8f0ee86b03 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt
@@ -23,4 +23,10 @@ interface InputMethodAdapter {
* around to the first subtype after the last.
*/
fun cycleInputMethodSubtype(): KMResult
+
+ /**
+ * Returns the input method that should become active next when cycling through all
+ * currently enabled input methods, in a fixed order, wrapping around after the last one.
+ */
+ fun getNextInputMethod(): KMResult
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt
similarity index 81%
rename from base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt
rename to system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt
index 4c72b8efe2..f54340ba0f 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt
@@ -1,4 +1,4 @@
-package io.github.sds100.keymapper.base.system.notifications
+package io.github.sds100.keymapper.system.notifications
import android.Manifest
import android.app.NotificationChannel
@@ -17,18 +17,16 @@ import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext
-import io.github.sds100.keymapper.base.R
-import io.github.sds100.keymapper.base.utils.ui.color
import io.github.sds100.keymapper.common.KeyMapperClassProvider
import io.github.sds100.keymapper.common.notifications.KMNotificationAction
-import io.github.sds100.keymapper.system.notifications.NotificationAdapter
-import io.github.sds100.keymapper.system.notifications.NotificationChannelModel
-import io.github.sds100.keymapper.system.notifications.NotificationModel
-import io.github.sds100.keymapper.system.notifications.NotificationRemoteInput
+import io.github.sds100.keymapper.common.utils.KMResult
+import io.github.sds100.keymapper.common.utils.Success
+import io.github.sds100.keymapper.system.R
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -37,6 +35,7 @@ class AndroidNotificationAdapter @Inject constructor(
@ApplicationContext private val ctx: Context,
private val coroutineScope: CoroutineScope,
private val classProvider: KeyMapperClassProvider,
+ private val notificationReceiverAdapter: NotificationReceiverAdapter,
) : NotificationAdapter {
private val manager: NotificationManagerCompat = NotificationManagerCompat.from(ctx)
@@ -44,6 +43,11 @@ class AndroidNotificationAdapter @Inject constructor(
override val onNotificationActionClick = MutableSharedFlow()
override val onNotificationRemoteInput = MutableSharedFlow()
+ private val postedNotificationStore = PostedNotificationStore()
+
+ override val activeNotifications: StateFlow> =
+ postedNotificationStore.active
+
private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
context ?: return
@@ -82,15 +86,16 @@ class AndroidNotificationAdapter @Inject constructor(
}
val builder = NotificationCompat.Builder(ctx, notification.channel).apply {
+ // The system tints the notification itself when dynamic colours are available.
if (!DynamicColors.isDynamicColorAvailable()) {
- color = ctx.color(R.color.md_theme_secondary)
+ color = ContextCompat.getColor(ctx, R.color.notification_accent)
}
setContentTitle(notification.title)
setContentText(notification.text)
if (notification.onClickAction != null) {
- val pendingIntent = createActionIntent(notification.onClickAction!!)
+ val pendingIntent = createActionIntent(notification.onClickAction)
setContentIntent(pendingIntent)
}
@@ -106,7 +111,7 @@ class AndroidNotificationAdapter @Inject constructor(
}
if (notification.timeout != null) {
- this.setTimeoutAfter(notification.timeout!!)
+ this.setTimeoutAfter(notification.timeout)
}
if (notification.showIndeterminateProgress) {
@@ -216,6 +221,7 @@ class AndroidNotificationAdapter @Inject constructor(
is KMNotificationAction.Broadcast -> createBroadcastPendingIntent(
notificationAction.intentAction.name,
)
+
is KMNotificationAction.RemoteInput -> createRemoteInputPendingIntent(
notificationAction.intentAction.name,
)
@@ -271,4 +277,43 @@ class AndroidNotificationAdapter @Inject constructor(
PendingIntent.FLAG_IMMUTABLE,
)
}
+
+ override suspend fun dismissAllNotifications(): KMResult<*> =
+ notificationReceiverAdapter.send(NotificationServiceEvent.DismissAllNotifications)
+
+ override suspend fun dismissLastNotification(): KMResult<*> {
+ val key = activeNotifications.value.lastOrNull()?.key ?: return Success(Unit)
+
+ return notificationReceiverAdapter.send(NotificationServiceEvent.DismissNotification(key))
+ }
+
+ /**
+ * Replace everything that is posted, for when the listener connects.
+ *
+ * Only for [NotificationReceiver].
+ */
+ fun seedNotifications(notifications: List) {
+ postedNotificationStore.seed(notifications)
+ }
+
+ /**
+ * Only for [NotificationReceiver].
+ */
+ fun onNotificationPosted(notification: PostedNotification) {
+ postedNotificationStore.onPosted(notification)
+ }
+
+ /**
+ * Only for [NotificationReceiver].
+ */
+ fun onNotificationRemoved(key: String) {
+ postedNotificationStore.onRemoved(key)
+ }
+
+ /**
+ * Only for [NotificationReceiver].
+ */
+ fun onNotificationListenerDisconnected() {
+ postedNotificationStore.clear()
+ }
}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt
index f62834cdac..a68b498bcf 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt
@@ -1,7 +1,9 @@
package io.github.sds100.keymapper.system.notifications
import io.github.sds100.keymapper.common.notifications.KMNotificationAction
+import io.github.sds100.keymapper.common.utils.KMResult
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
interface NotificationAdapter {
/**
@@ -14,9 +16,25 @@ interface NotificationAdapter {
*/
val onNotificationRemoteInput: Flow
+ /**
+ * The notifications that are posted right now. This requires the notification listener
+ * permission and is empty without it.
+ */
+ val activeNotifications: StateFlow>
+
fun showNotification(notification: NotificationModel)
fun dismissNotification(notificationId: Int)
fun createChannel(channel: NotificationChannelModel)
fun deleteChannel(channelId: String)
fun openChannelSettings(channelId: String)
+
+ /**
+ * Dismiss every notification that Key Mapper is allowed to dismiss.
+ */
+ suspend fun dismissAllNotifications(): KMResult<*>
+
+ /**
+ * Dismiss the notification that was posted most recently. Does nothing if none are posted.
+ */
+ suspend fun dismissLastNotification(): KMResult<*>
}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt
index 0d67e19aac..3a223dee07 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt
@@ -1,5 +1,6 @@
package io.github.sds100.keymapper.system.notifications
+import android.app.Notification
import android.content.ComponentName
import android.media.session.MediaSessionManager
import android.service.notification.NotificationListenerService
@@ -37,11 +38,12 @@ class NotificationReceiver :
mediaAdapter.onActiveMediaSessionChange(controllers ?: emptyList())
}
- private var lastNotificationKey: String? = null
-
@Inject
lateinit var serviceAdapter: NotificationReceiverAdapterImpl
+ @Inject
+ lateinit var notificationAdapter: AndroidNotificationAdapter
+
private lateinit var lifecycleRegistry: LifecycleRegistry
override fun onCreate() {
@@ -53,13 +55,10 @@ class NotificationReceiver :
serviceAdapter.eventsToService
.onEach { event ->
when (event) {
- NotificationServiceEvent.DismissLastNotification -> cancelNotification(
- lastNotificationKey,
- )
+ is NotificationServiceEvent.DismissNotification ->
+ cancelNotification(event.key)
NotificationServiceEvent.DismissAllNotifications -> cancelAllNotifications()
-
- else -> Unit
}
}.launchIn(lifecycleScope)
}
@@ -72,20 +71,20 @@ class NotificationReceiver :
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
- lastNotificationKey = sbn?.key
+ sbn?.let { notificationAdapter.onNotificationPosted(it.toPostedNotification()) }
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
- if (sbn?.key == lastNotificationKey) {
- lastNotificationKey = null
- }
+ sbn?.let { notificationAdapter.onNotificationRemoved(it.key) }
}
override fun onListenerConnected() {
super.onListenerConnected()
+ seedPostedNotifications()
+
try {
mediaSessionManager.addOnActiveSessionsChangedListener(
activeSessionsChangeListener,
@@ -108,9 +107,44 @@ class NotificationReceiver :
mediaAdapter.onActiveMediaSessionChange(emptyList())
+ notificationAdapter.onNotificationListenerDisconnected()
+
super.onListenerDisconnected()
}
+ /**
+ * Notifications can be posted while the listener is not running, so the list has to be read
+ * once on connecting rather than relying on [onNotificationPosted] alone.
+ */
+ private fun seedPostedNotifications() {
+ try {
+ val notifications = activeNotifications
+ ?.map { it.toPostedNotification() }
+ ?: emptyList()
+
+ notificationAdapter.seedNotifications(notifications)
+ } catch (e: SecurityException) {
+ // Thrown when the listener is not connected yet.
+ Timber.e("NotificationReceiver: Failed to read the posted notifications. $e")
+ }
+ }
+
+ private fun StatusBarNotification.toPostedNotification(): PostedNotification {
+ val extras = notification.extras
+
+ return PostedNotification(
+ key = key,
+ packageName = packageName,
+ title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(),
+ // Fall back so that notifications using an expanded style still have matchable text.
+ text = (
+ extras.getCharSequence(Notification.EXTRA_TEXT)
+ ?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
+ ?: extras.getCharSequence(Notification.EXTRA_SUB_TEXT)
+ )?.toString(),
+ )
+ }
+
override val lifecycle: Lifecycle
get() = lifecycleRegistry
}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt
index cfc6fe15bb..8cae664383 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt
@@ -4,8 +4,11 @@ import kotlinx.serialization.Serializable
sealed class NotificationServiceEvent {
+ /**
+ * @param key the [PostedNotification.key] of the notification to dismiss.
+ */
@Serializable
- data object DismissLastNotification : NotificationServiceEvent()
+ data class DismissNotification(val key: String) : NotificationServiceEvent()
@Serializable
data object DismissAllNotifications : NotificationServiceEvent()
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt
new file mode 100644
index 0000000000..280aef3208
--- /dev/null
+++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt
@@ -0,0 +1,15 @@
+package io.github.sds100.keymapper.system.notifications
+
+/**
+ * A notification posted by another app.
+ */
+data class PostedNotification(
+ /**
+ * The key the system uses to identify this notification. Apps reuse it when they update a
+ * notification in place.
+ */
+ val key: String,
+ val packageName: String,
+ val title: String?,
+ val text: String?,
+)
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt
new file mode 100644
index 0000000000..8237fa1d16
--- /dev/null
+++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt
@@ -0,0 +1,52 @@
+package io.github.sds100.keymapper.system.notifications
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Holds the notifications that [NotificationReceiver] has seen and that are still posted.
+ *
+ * This is deliberately free of any Android dependency so that its behaviour can be tested, and it
+ * lives outside the service because the system destroys and rebinds a notification listener on
+ * permission changes and memory pressure, which would wipe state kept on the service itself.
+ *
+ * Everything here is in memory only and is never written to disk.
+ */
+class PostedNotificationStore {
+ private val _active = MutableStateFlow>(emptyList())
+
+ /**
+ * The notifications that are posted right now, oldest first. Anything that dismisses the most
+ * recent notification relies on this order, so new notifications always go on the end.
+ */
+ val active: StateFlow> = _active.asStateFlow()
+
+ /**
+ * Replace everything that is posted, for when the listener connects and notifications may have
+ * been posted while it was not running.
+ */
+ fun seed(notifications: List) {
+ _active.update { notifications }
+ }
+
+ fun onPosted(notification: PostedNotification) {
+ _active.update { active ->
+ // Updating a notification in place moves it to the end, because the app just posted it.
+ active.filterNot { it.key == notification.key } + notification
+ }
+ }
+
+ fun onRemoved(key: String) {
+ _active.update { active -> active.filterNot { it.key == key } }
+ }
+
+ /**
+ * Losing the listener may mean the permission was revoked, so no notification content should be
+ * left in memory.
+ */
+ fun clear() {
+ _active.update { emptyList() }
+ }
+}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt
index 6dc2dd58fe..4ebd1842ec 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt
@@ -116,7 +116,11 @@ class AndroidPhoneAdapter @Inject constructor(
}
}
- override fun getCallState(): CallState {
+ override fun getCallState(): CallState? {
+ if (!hasReadPhoneStatePermission()) {
+ return null
+ }
+
if (telephonyManager == null) {
throw Exception("TelephonyManager is null. Does this device support telephony?")
}
@@ -154,6 +158,13 @@ class AndroidPhoneAdapter @Inject constructor(
) == PackageManager.PERMISSION_GRANTED
}
+ private fun hasReadPhoneStatePermission(): Boolean {
+ return ActivityCompat.checkSelfPermission(
+ ctx,
+ Manifest.permission.READ_PHONE_STATE,
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
@SuppressLint("MissingPermission")
override fun endCall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -232,8 +243,11 @@ class AndroidPhoneAdapter @Inject constructor(
private fun callStateConverter(sdkCallState: Int): CallState {
when (sdkCallState) {
TelephonyManager.CALL_STATE_IDLE -> return CallState.NONE
+
TelephonyManager.CALL_STATE_OFFHOOK -> return CallState.IN_PHONE_CALL
+
TelephonyManager.CALL_STATE_RINGING -> return CallState.RINGING
+
else -> throw IllegalArgumentException(
"Don't know how to convert that call state $sdkCallState",
)
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt
index 5229852025..13e48be569 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt
@@ -10,7 +10,7 @@ interface PhoneAdapter {
* Must check if Key Mapper has READ_PHONE_STATE permission before calling this. Otherwise
* a security exception will be thrown.
*/
- fun getCallState(): CallState
+ fun getCallState(): CallState?
fun startCall(number: String): KMResult<*>
fun answerCall()
fun endCall()
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt
index 30e14f32a6..e23bcb88dc 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt
@@ -5,6 +5,8 @@ import android.os.Build
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -15,26 +17,53 @@ class AndroidVibratorAdapter @Inject constructor(@ApplicationContext private val
VibratorAdapter {
private val vibrator: Vibrator? = context.getSystemService()
+ @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
+ override val supportsPredefinedEffects: Boolean =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+
override fun vibrate(duration: Long) {
if (duration <= 0) return
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val effect =
- VibrationEffect.createOneShot(
- duration,
- VibrationEffect.DEFAULT_AMPLITUDE,
- )
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- vibrator?.vibrate(
- effect,
- VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY),
- )
- } else {
- vibrator?.vibrate(effect)
- }
+ val effect =
+ VibrationEffect.createOneShot(
+ duration,
+ VibrationEffect.DEFAULT_AMPLITUDE,
+ )
+
+ vibrate(effect)
+ }
+
+ override fun vibrate(effect: PredefinedVibrationEffect) {
+ if (supportsPredefinedEffects) {
+ val platformEffect = VibrationEffect.createPredefined(effect.toPlatformEffectId())
+ vibrate(platformEffect)
+ } else {
+ // Predefined effects need API 29. Fall back to a short buzz so the
+ // action/trigger still does something on older devices.
+ vibrate(FALLBACK_PREDEFINED_EFFECT_DURATION_MS)
+ }
+ }
+
+ private fun vibrate(effect: VibrationEffect) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ vibrator?.vibrate(
+ effect,
+ VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY),
+ )
} else {
- vibrator?.vibrate(duration)
+ vibrator?.vibrate(effect)
}
}
+
+ @RequiresApi(Build.VERSION_CODES.Q)
+ private fun PredefinedVibrationEffect.toPlatformEffectId(): Int = when (this) {
+ PredefinedVibrationEffect.CLICK -> VibrationEffect.EFFECT_CLICK
+ PredefinedVibrationEffect.DOUBLE_CLICK -> VibrationEffect.EFFECT_DOUBLE_CLICK
+ PredefinedVibrationEffect.HEAVY_CLICK -> VibrationEffect.EFFECT_HEAVY_CLICK
+ PredefinedVibrationEffect.TICK -> VibrationEffect.EFFECT_TICK
+ }
+
+ companion object {
+ private const val FALLBACK_PREDEFINED_EFFECT_DURATION_MS = 40L
+ }
}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt
new file mode 100644
index 0000000000..096c11f86e
--- /dev/null
+++ b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt
@@ -0,0 +1,8 @@
+package io.github.sds100.keymapper.system.vibrator
+
+enum class PredefinedVibrationEffect {
+ CLICK,
+ DOUBLE_CLICK,
+ HEAVY_CLICK,
+ TICK,
+}
diff --git a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt
index ac717776b1..faad30a8bb 100644
--- a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt
+++ b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt
@@ -1,5 +1,12 @@
package io.github.sds100.keymapper.system.vibrator
interface VibratorAdapter {
+ /**
+ * Whether [vibrate] with a [PredefinedVibrationEffect] plays a distinct effect on this
+ * device rather than falling back to a generic buzz.
+ */
+ val supportsPredefinedEffects: Boolean
+
fun vibrate(duration: Long)
+ fun vibrate(effect: PredefinedVibrationEffect)
}
diff --git a/system/src/main/res/values-night/colors.xml b/system/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000000..8f285c970c
--- /dev/null
+++ b/system/src/main/res/values-night/colors.xml
@@ -0,0 +1,5 @@
+
+
+
+ #BEC6DC
+
diff --git a/system/src/main/res/values/colors.xml b/system/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..d94ab92b0d
--- /dev/null
+++ b/system/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+
+ #565F71
+